chore: update merge branch with latest v2
This commit is contained in:
commit
4386b3cb6f
250 changed files with 8514 additions and 2004 deletions
19
.github/workflows/publish.yml
vendored
19
.github/workflows/publish.yml
vendored
|
|
@ -90,11 +90,18 @@ jobs:
|
||||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build legacy CLI
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
|
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
||||||
|
- name: Build preview CLI
|
||||||
id: build
|
id: build
|
||||||
run: |
|
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
|
||||||
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
|
@ -102,6 +109,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: |
|
path: |
|
||||||
|
|
@ -109,6 +117,7 @@ jobs:
|
||||||
packages/opencode/dist/opencode-linux*
|
packages/opencode/dist/opencode-linux*
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist/opencode-windows*
|
path: packages/opencode/dist/opencode-windows*
|
||||||
|
|
@ -491,11 +500,13 @@ jobs:
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
|
||||||
134
bun.lock
134
bun.lock
|
|
@ -960,6 +960,20 @@
|
||||||
"solid-js": "^1.9.0",
|
"solid-js": "^1.9.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/updates": {
|
||||||
|
"name": "@opencode-ai/updates",
|
||||||
|
"version": "1.18.4",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "6.0.11",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "catalog:",
|
||||||
|
"@tsconfig/node22": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"wrangler": "4.110.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.18.3",
|
"version": "1.18.3",
|
||||||
|
|
@ -2119,6 +2133,8 @@
|
||||||
|
|
||||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||||
|
|
||||||
|
"@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"],
|
||||||
|
|
||||||
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
|
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
|
||||||
|
|
||||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||||
|
|
@ -6587,6 +6603,8 @@
|
||||||
|
|
||||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
|
|
||||||
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
|
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
|
||||||
|
|
@ -7857,6 +7875,16 @@
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
@ -8705,6 +8733,72 @@
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
|
||||||
|
|
||||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/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-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/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-ai/www/@cloudflare/vite-plugin/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-ai/www/@cloudflare/vite-plugin/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=="],
|
||||||
|
|
@ -8989,6 +9083,44 @@
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/updates/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-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/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-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/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-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||||
|
|
||||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
|
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
|
||||||
|
|
||||||
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
|
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
|
||||||
|
|
@ -9139,6 +9271,8 @@
|
||||||
|
|
||||||
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||||
|
|
||||||
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||||
|
|
||||||
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
const ADAPTER = "anthropic-messages"
|
const ADAPTER = "anthropic-messages"
|
||||||
|
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||||
export const PATH = "/messages"
|
export const PATH = "/messages"
|
||||||
|
|
||||||
|
|
@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({
|
||||||
})
|
})
|
||||||
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
||||||
|
|
||||||
|
const AnthropicDocumentBlock = Schema.Struct({
|
||||||
|
type: Schema.tag("document"),
|
||||||
|
source: Schema.Struct({
|
||||||
|
type: Schema.tag("base64"),
|
||||||
|
media_type: Schema.Literal("application/pdf"),
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
|
})
|
||||||
|
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
|
||||||
|
|
||||||
const AnthropicThinkingBlock = Schema.Struct({
|
const AnthropicThinkingBlock = Schema.Struct({
|
||||||
type: Schema.tag("thinking"),
|
type: Schema.tag("thinking"),
|
||||||
thinking: Schema.String,
|
thinking: Schema.String,
|
||||||
|
|
@ -101,13 +113,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
|
||||||
})
|
})
|
||||||
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
|
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
|
||||||
|
|
||||||
// Anthropic accepts either a plain string or an ordered array of text/image
|
// Anthropic accepts either a plain string or an ordered array of text, image, and
|
||||||
// blocks inside `tool_result.content`. The array form is required when a tool
|
// document blocks inside `tool_result.content`. The array form keeps media as native
|
||||||
// returns image bytes (screenshot, image search, etc.) so they can be passed
|
// model input instead of JSON-stringifying base64 into prompt text.
|
||||||
// to the model as proper image inputs instead of being JSON-stringified into
|
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
|
||||||
// the prompt — which silently inflates context by megabytes and can push the
|
|
||||||
// conversation over the model's token limit.
|
|
||||||
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
|
|
||||||
|
|
||||||
const AnthropicToolResultBlock = Schema.Struct({
|
const AnthropicToolResultBlock = Schema.Struct({
|
||||||
type: Schema.tag("tool_result"),
|
type: Schema.tag("tool_result"),
|
||||||
|
|
@ -117,7 +126,12 @@ const AnthropicToolResultBlock = Schema.Struct({
|
||||||
cache_control: Schema.optional(AnthropicCacheControl),
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
})
|
})
|
||||||
|
|
||||||
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
|
const AnthropicUserBlock = Schema.Union([
|
||||||
|
AnthropicTextBlock,
|
||||||
|
AnthropicImageBlock,
|
||||||
|
AnthropicDocumentBlock,
|
||||||
|
AnthropicToolResultBlock,
|
||||||
|
])
|
||||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||||
const AnthropicAssistantBlock = Schema.Union([
|
const AnthropicAssistantBlock = Schema.Union([
|
||||||
AnthropicTextBlock,
|
AnthropicTextBlock,
|
||||||
|
|
@ -319,12 +333,17 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
|
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||||
const media = yield* ProviderShared.validateMedia(
|
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
|
||||||
"Anthropic Messages",
|
if (media.mime === "application/pdf")
|
||||||
part,
|
return {
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
type: "document" as const,
|
||||||
)
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: "application/pdf" as const,
|
||||||
|
data: media.base64,
|
||||||
|
},
|
||||||
|
} satisfies AnthropicDocumentBlock
|
||||||
return {
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
source: {
|
source: {
|
||||||
|
|
@ -335,25 +354,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
|
||||||
} satisfies AnthropicImageBlock
|
} satisfies AnthropicImageBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
// Tool results may carry structured text/images. Keep media as provider-native
|
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||||
item: ToolContent,
|
item: ToolContent,
|
||||||
) {
|
) {
|
||||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||||
const media = yield* ProviderShared.validateToolFile(
|
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||||
"Anthropic Messages",
|
|
||||||
item,
|
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
type: "image" as const,
|
|
||||||
source: {
|
|
||||||
type: "base64" as const,
|
|
||||||
media_type: media.mime,
|
|
||||||
data: media.base64,
|
|
||||||
},
|
|
||||||
} satisfies AnthropicImageBlock
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
|
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||||
|
|
@ -445,7 +452,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "media") {
|
if (part.type === "media") {
|
||||||
content.push(yield* lowerImage(part))
|
content.push(yield* lowerMedia(part))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const BedrockToolResultContentItem = Schema.Union([
|
||||||
Schema.Struct({ text: Schema.String }),
|
Schema.Struct({ text: Schema.String }),
|
||||||
Schema.Struct({ json: Schema.Unknown }),
|
Schema.Struct({ json: Schema.Unknown }),
|
||||||
BedrockMedia.ImageBlock,
|
BedrockMedia.ImageBlock,
|
||||||
|
BedrockMedia.DocumentBlock,
|
||||||
])
|
])
|
||||||
|
|
||||||
const BedrockToolResultBlock = Schema.Struct({
|
const BedrockToolResultBlock = Schema.Struct({
|
||||||
|
|
@ -283,8 +284,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||||
data: item.uri,
|
data: item.uri,
|
||||||
filename: item.name,
|
filename: item.name,
|
||||||
})
|
})
|
||||||
if (!("image" in media))
|
|
||||||
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
|
|
||||||
content.push(media)
|
content.push(media)
|
||||||
}
|
}
|
||||||
return content
|
return content
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({
|
||||||
data: Schema.String,
|
data: Schema.String,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||||
|
|
||||||
const GeminiFunctionCallPart = Schema.Struct({
|
const GeminiFunctionCallPart = Schema.Struct({
|
||||||
functionCall: Schema.Struct({
|
functionCall: Schema.Struct({
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.Unknown,
|
||||||
}),
|
}),
|
||||||
|
|
@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({
|
||||||
|
|
||||||
const GeminiFunctionResponsePart = Schema.Struct({
|
const GeminiFunctionResponsePart = Schema.Struct({
|
||||||
functionResponse: Schema.Struct({
|
functionResponse: Schema.Struct({
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
response: Schema.Unknown,
|
response: Schema.Unknown,
|
||||||
|
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||||
|
const google = providerMetadata?.google
|
||||||
|
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart) => ({
|
const lowerToolCall = (part: ToolCallPart) => ({
|
||||||
functionCall: { name: part.name, args: part.input },
|
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||||
if (part.result.type !== "content") {
|
if (part.result.type !== "content") {
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
|
id: functionCallId(part.providerMetadata),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
|
|
@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||||
}
|
}
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||||
|
const media: GeminiInlineDataPart[] = []
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === "text") continue
|
||||||
|
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
||||||
|
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||||
|
}
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
|
id: functionCallId(part.providerMetadata),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
content: text.join("\n"),
|
content: text.join("\n"),
|
||||||
},
|
},
|
||||||
|
parts: media.length > 0 ? media : undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
for (const item of content) {
|
|
||||||
if (item.type === "text") continue
|
|
||||||
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
|
||||||
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
contents.push({ role: "user", parts })
|
contents.push({ role: "user", parts })
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +454,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
|
const metadata = {
|
||||||
|
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||||
|
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||||
|
}
|
||||||
lifecycle = Lifecycle.reasoningEnd(
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
lifecycle,
|
lifecycle,
|
||||||
events,
|
events,
|
||||||
|
|
@ -453,9 +470,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||||
id,
|
id,
|
||||||
name: part.functionCall.name,
|
name: part.functionCall.name,
|
||||||
input,
|
input,
|
||||||
providerMetadata: part.thoughtSignature
|
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
|
||||||
: undefined,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
hasToolCalls = true
|
hasToolCalls = true
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
const ADAPTER = "openai-chat"
|
const ADAPTER = "openai-chat"
|
||||||
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
||||||
|
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/chat/completions"
|
export const PATH = "/chat/completions"
|
||||||
|
|
||||||
|
|
@ -70,15 +71,18 @@ const OpenAIChatMessage = Schema.Union([
|
||||||
role: Schema.Literal("user"),
|
role: Schema.Literal("user"),
|
||||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.StructWithRest(
|
||||||
role: Schema.Literal("assistant"),
|
Schema.Struct({
|
||||||
content: Schema.NullOr(Schema.String),
|
role: Schema.Literal("assistant"),
|
||||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
content: Schema.NullOr(Schema.String),
|
||||||
reasoning_content: Schema.optional(Schema.String),
|
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||||
reasoning: Schema.optional(Schema.String),
|
reasoning_content: Schema.optional(Schema.String),
|
||||||
reasoning_text: Schema.optional(Schema.String),
|
reasoning: Schema.optional(Schema.String),
|
||||||
reasoning_details: optionalArray(Schema.Unknown),
|
reasoning_text: Schema.optional(Schema.String),
|
||||||
}),
|
reasoning_details: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||||
|
|
@ -145,14 +149,17 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||||
})
|
})
|
||||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||||
|
|
||||||
const OpenAIChatDelta = Schema.Struct({
|
const OpenAIChatDelta = Schema.StructWithRest(
|
||||||
content: optionalNull(Schema.String),
|
Schema.Struct({
|
||||||
reasoning_content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
reasoning: optionalNull(Schema.String),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
reasoning_text: optionalNull(Schema.String),
|
reasoning: optionalNull(Schema.String),
|
||||||
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
|
reasoning_text: optionalNull(Schema.String),
|
||||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
reasoning_details: optionalNull(Schema.Unknown),
|
||||||
})
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
const OpenAIChatChoice = Schema.Struct({
|
const OpenAIChatChoice = Schema.Struct({
|
||||||
delta: optionalNull(OpenAIChatDelta),
|
delta: optionalNull(OpenAIChatDelta),
|
||||||
|
|
@ -179,7 +186,7 @@ export interface ParserState {
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReason
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
readonly reasoningField?: string
|
||||||
readonly reasoningDetails: Array<unknown>
|
readonly reasoningDetails: Array<unknown>
|
||||||
readonly reasoningDetailsObserved: boolean
|
readonly reasoningDetailsObserved: boolean
|
||||||
readonly reasoningEmitted: boolean
|
readonly reasoningEmitted: boolean
|
||||||
|
|
@ -227,7 +234,7 @@ const openAICompatibleReasoningContent = (native: unknown) =>
|
||||||
|
|
||||||
const reasoningField = (part: ReasoningPart) => {
|
const reasoningField = (part: ReasoningPart) => {
|
||||||
const field = part.providerMetadata?.openai?.reasoningField
|
const field = part.providerMetadata?.openai?.reasoningField
|
||||||
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
return typeof field === "string" ? field : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||||
|
|
@ -259,6 +266,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
||||||
|
|
||||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||||
message: OpenAIChatRequestMessage,
|
message: OpenAIChatRequestMessage,
|
||||||
|
configuredField?: string,
|
||||||
) {
|
) {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
const reasoning: ReasoningPart[] = []
|
const reasoning: ReasoningPart[] = []
|
||||||
|
|
@ -285,24 +293,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||||
const field = (() => {
|
const field = (() => {
|
||||||
if (reasoning.length === 0) return
|
if (configuredField !== undefined) return configuredField
|
||||||
|
if (reasoning.length === 0) return undefined
|
||||||
if (observedField !== undefined) return observedField
|
if (observedField !== undefined) return observedField
|
||||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||||
if (!fullyStructured) return "reasoning_content"
|
if (!fullyStructured) return "reasoning_content"
|
||||||
})()
|
})()
|
||||||
const reasoningContent = (() => {
|
const reasoningText = (() => {
|
||||||
|
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
|
||||||
if (reasoning.length === 0) return nativeReasoning
|
if (reasoning.length === 0) return nativeReasoning
|
||||||
if (field === "reasoning_content") return text
|
return text
|
||||||
})()
|
})()
|
||||||
return {
|
const result = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content: reasoningContent,
|
|
||||||
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
|
||||||
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
|
|
||||||
reasoning_details: details,
|
reasoning_details: details,
|
||||||
}
|
}
|
||||||
|
if (field === undefined || reasoningText === undefined) return result
|
||||||
|
return { ...result, [field]: reasoningText }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||||
|
|
@ -328,9 +337,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||||
return { messages, images }
|
return { messages, images }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
|
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||||
|
message: OpenAIChatRequestMessage,
|
||||||
|
reasoningField?: string,
|
||||||
|
) {
|
||||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
|
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||||
return (yield* lowerToolMessages(message)).messages
|
return (yield* lowerToolMessages(message)).messages
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -368,7 +380,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
flushImages()
|
flushImages()
|
||||||
messages.push(...(yield* lowerMessage(message)))
|
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||||
}
|
}
|
||||||
flushImages()
|
flushImages()
|
||||||
return messages
|
return messages
|
||||||
|
|
@ -386,6 +398,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL
|
||||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||||
// validation, and HTTP execution are composed by `Route.make`.
|
// validation, and HTTP execution are composed by `Route.make`.
|
||||||
|
const reasoningField = request.model.compatibility?.reasoningField
|
||||||
|
if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField))
|
||||||
|
return yield* ProviderShared.invalidRequest(
|
||||||
|
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
|
||||||
|
)
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
return {
|
return {
|
||||||
|
|
@ -446,10 +463,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
|
const reasoningDelta = (
|
||||||
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
|
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
||||||
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
|
configuredField?: string,
|
||||||
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
|
) => {
|
||||||
|
if (!delta) return undefined
|
||||||
|
const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"])
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field === undefined) continue
|
||||||
|
const text = delta[field]
|
||||||
|
if (typeof text === "string" && text.length > 0) return { field, text }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||||
|
|
@ -518,7 +543,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
const reasoning = reasoningDelta(delta)
|
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
|
|
@ -635,12 +660,12 @@ export const protocol = Protocol.make({
|
||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||||
initial: () => ({
|
initial: (request) => ({
|
||||||
tools: ToolStream.empty<number>(),
|
tools: ToolStream.empty<number>(),
|
||||||
pendingTools: {},
|
pendingTools: {},
|
||||||
toolCallEvents: [],
|
toolCallEvents: [],
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
reasoningField: undefined,
|
reasoningField: request.model.compatibility?.reasoningField,
|
||||||
reasoningDetails: [],
|
reasoningDetails: [],
|
||||||
reasoningDetailsObserved: false,
|
reasoningDetailsObserved: false,
|
||||||
reasoningEmitted: false,
|
reasoningEmitted: false,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
|
type MediaPart,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ReasoningPart,
|
type ReasoningPart,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
|
|
@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
|
||||||
import { OpenAIImage } from "./utils/openai-image"
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
const ADAPTER = "openai-responses"
|
const ADAPTER = "openai-responses"
|
||||||
|
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/responses"
|
export const PATH = "/responses"
|
||||||
|
|
||||||
|
|
@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({
|
||||||
type: Schema.tag("input_image"),
|
type: Schema.tag("input_image"),
|
||||||
image_url: Schema.String,
|
image_url: Schema.String,
|
||||||
})
|
})
|
||||||
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
|
const OpenAIResponsesInputFile = Schema.Struct({
|
||||||
|
type: Schema.tag("input_file"),
|
||||||
|
filename: Schema.String,
|
||||||
|
file_data: Schema.String,
|
||||||
|
mime_type: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
const OpenAIResponsesInputContent = Schema.Union([
|
||||||
|
OpenAIResponsesInputText,
|
||||||
|
OpenAIResponsesInputImage,
|
||||||
|
OpenAIResponsesInputFile,
|
||||||
|
])
|
||||||
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
|
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
|
||||||
|
|
||||||
const OpenAIResponsesOutputText = Schema.Struct({
|
const OpenAIResponsesOutputText = Schema.Struct({
|
||||||
|
|
@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({
|
||||||
})
|
})
|
||||||
|
|
||||||
// `function_call_output.output` accepts either a plain string or an ordered
|
// `function_call_output.output` accepts either a plain string or an ordered
|
||||||
// array of content items so tools can return images in addition to text.
|
// array of content items so tools can return images and files in addition to text.
|
||||||
// https://platform.openai.com/docs/api-reference/responses/object
|
// https://platform.openai.com/docs/api-reference/responses/object
|
||||||
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
|
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
|
||||||
|
OpenAIResponsesInputText,
|
||||||
|
OpenAIResponsesInputImage,
|
||||||
|
OpenAIResponsesInputFile,
|
||||||
|
])
|
||||||
|
|
||||||
const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
||||||
Schema.String,
|
Schema.String,
|
||||||
|
|
@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
|
const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
|
||||||
part: LLMRequest["messages"][number]["content"][number],
|
const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
|
||||||
) {
|
if (media.mime === "application/pdf") {
|
||||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
// xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
|
||||||
if (part.type === "media") {
|
if (provider === "xai")
|
||||||
const media = yield* ProviderShared.validateMedia(
|
return {
|
||||||
"OpenAI Responses",
|
type: "input_file" as const,
|
||||||
part,
|
filename: part.filename ?? "document.pdf",
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
file_data: media.base64,
|
||||||
)
|
mime_type: media.mime,
|
||||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
}
|
||||||
|
return {
|
||||||
|
type: "input_file" as const,
|
||||||
|
filename: part.filename ?? "document.pdf",
|
||||||
|
file_data: media.dataUrl,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tool results may carry structured text/images. Keep media as provider-native
|
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
|
||||||
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
|
|
||||||
item: ToolContent,
|
|
||||||
) {
|
|
||||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
|
||||||
const media = yield* ProviderShared.validateToolFile(
|
|
||||||
"OpenAI Responses",
|
|
||||||
item,
|
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
|
||||||
)
|
|
||||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
|
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
|
||||||
|
part: LLMRequest["messages"][number]["content"][number],
|
||||||
|
provider: string,
|
||||||
|
) {
|
||||||
|
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||||
|
if (part.type === "media") return yield* lowerMedia(part, provider)
|
||||||
|
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||||
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
|
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
|
||||||
|
item: ToolContent,
|
||||||
|
provider: string,
|
||||||
|
) {
|
||||||
|
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||||
|
return yield* lowerMedia(
|
||||||
|
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
|
||||||
|
part: ToolResultPart,
|
||||||
|
provider: string,
|
||||||
|
) {
|
||||||
// Text/json/error results are encoded as a plain string for backward
|
// Text/json/error results are encoded as a plain string for backward
|
||||||
// compatibility with existing cassettes and provider expectations.
|
// compatibility with existing cassettes and provider expectations.
|
||||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
||||||
|
|
@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
|
input.push({
|
||||||
|
role: "user",
|
||||||
|
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||||
input.push({
|
input.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: yield* Effect.forEach(content, lowerToolResultContentItem),
|
content: yield* Effect.forEach(content, (item) =>
|
||||||
|
lowerToolResultContentItem(item, request.model.provider),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (itemID) hostedToolReferences.add(itemID)
|
if (itemID) hostedToolReferences.add(itemID)
|
||||||
|
|
@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
input.push({
|
input.push({
|
||||||
type: "function_call_output",
|
type: "function_call_output",
|
||||||
call_id: part.id,
|
call_id: part.id,
|
||||||
output: yield* lowerToolResultOutput(part),
|
output: yield* lowerToolResultOutput(part, request.model.provider),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
|
||||||
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
||||||
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
||||||
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
||||||
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
|
export const PDF_MIMES = ["application/pdf"] as const
|
||||||
|
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
|
||||||
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
||||||
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
|
||||||
"text/markdown": "md",
|
"text/markdown": "md",
|
||||||
} as const satisfies Record<string, DocumentFormat>
|
} as const satisfies Record<string, DocumentFormat>
|
||||||
|
|
||||||
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
||||||
document: {
|
document: {
|
||||||
format,
|
format,
|
||||||
name: part.filename ?? `document.${format}`,
|
name,
|
||||||
source: { bytes },
|
source: { bytes },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||||
if (documentFormat) {
|
if (documentFormat) {
|
||||||
|
if (!part.filename)
|
||||||
|
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||||
const media = yield* ProviderShared.validateMedia(
|
const media = yield* ProviderShared.validateMedia(
|
||||||
"Bedrock Converse",
|
"Bedrock Converse",
|
||||||
part,
|
part,
|
||||||
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
||||||
)
|
)
|
||||||
return documentBlock(part, documentFormat, media.base64)
|
return documentBlock(part.filename, documentFormat, media.base64)
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSc
|
||||||
|
|
||||||
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
||||||
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
||||||
|
reasoningField: Schema.optional(Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export namespace ModelCompatibility {
|
export namespace ModelCompatibility {
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||||
events:
|
events:
|
||||||
settlement.result.type === "error"
|
settlement.result.type === "error"
|
||||||
? [
|
? [
|
||||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
|
LLMEvent.toolError({
|
||||||
LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
message: String(settlement.result.value),
|
||||||
|
error,
|
||||||
|
providerMetadata: call.providerMetadata,
|
||||||
|
}),
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
result: settlement.result,
|
||||||
|
providerMetadata: call.providerMetadata,
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
|
: [
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
result: settlement.result,
|
||||||
|
output: settlement.output,
|
||||||
|
providerMetadata: call.providerMetadata,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json
vendored
Normal file
35
packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:anthropic",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"tool",
|
||||||
|
"tool-result"
|
||||||
|
],
|
||||||
|
"name": "pdf/anthropic-tool-result",
|
||||||
|
"recordedAt": "2026-07-22T18:15:39.002Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:anthropic",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"user-input"
|
||||||
|
],
|
||||||
|
"name": "pdf/anthropic-user-input",
|
||||||
|
"recordedAt": "2026-07-22T18:15:37.979Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
36
packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json
vendored
Normal file
36
packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:amazon-bedrock",
|
||||||
|
"protocol:bedrock-converse",
|
||||||
|
"tool",
|
||||||
|
"tool-result"
|
||||||
|
],
|
||||||
|
"name": "pdf/bedrock-tool-result",
|
||||||
|
"recordedAt": "2026-07-22T18:15:52.400Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/vnd.amazon.eventstream"
|
||||||
|
},
|
||||||
|
"body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=",
|
||||||
|
"bodyEncoding": "base64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json
vendored
Normal file
35
packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:amazon-bedrock",
|
||||||
|
"protocol:bedrock-converse",
|
||||||
|
"user-input"
|
||||||
|
],
|
||||||
|
"name": "pdf/bedrock-user-input",
|
||||||
|
"recordedAt": "2026-07-22T18:15:48.408Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/vnd.amazon.eventstream"
|
||||||
|
},
|
||||||
|
"body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
|
||||||
|
"bodyEncoding": "base64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json
vendored
Normal file
53
packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:google",
|
||||||
|
"protocol:gemini",
|
||||||
|
"tool",
|
||||||
|
"tool-result"
|
||||||
|
],
|
||||||
|
"name": "pdf/gemini-tool-result",
|
||||||
|
"recordedAt": "2026-07-22T18:21:59.606Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:pdf",
|
||||||
|
"pdf",
|
||||||
|
"provider:google",
|
||||||
|
"protocol:gemini",
|
||||||
|
"user-input"
|
||||||
|
],
|
||||||
|
"name": "pdf/gemini-user-input",
|
||||||
|
"recordedAt": "2026-07-22T18:20:55.140Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json
vendored
Normal file
35
packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json
vendored
Normal file
File diff suppressed because one or more lines are too long
34
packages/ai/test/fixtures/recordings/pdf/openai-user-input.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/pdf/openai-user-input.json
vendored
Normal file
File diff suppressed because one or more lines are too long
35
packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json
vendored
Normal file
35
packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json
vendored
Normal file
File diff suppressed because one or more lines are too long
34
packages/ai/test/fixtures/recordings/pdf/xai-user-input.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/pdf/xai-user-input.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -59,7 +59,12 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
||||||
...request.messages,
|
...request.messages,
|
||||||
Message.assistant(state.assistantContent),
|
Message.assistant(state.assistantContent),
|
||||||
...dispatched.map(([call, dispatched]) =>
|
...dispatched.map(([call, dispatched]) =>
|
||||||
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
|
Message.tool({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
result: dispatched.result,
|
||||||
|
providerMetadata: call.providerMetadata,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -235,9 +235,9 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Regression: screenshot/read tool results must stay structured so base64
|
// Regression: read tool results must stay structured so base64 media data is
|
||||||
// image data is not JSON-stringified into `tool_result.content`.
|
// not JSON-stringified into `tool_result.content`.
|
||||||
it.effect("lowers image tool-result content as structured image blocks", () =>
|
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
@ -253,6 +253,7 @@ describe("Anthropic Messages route", () => {
|
||||||
result: [
|
result: [
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
|
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
|
||||||
|
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
@ -263,6 +264,7 @@ describe("Anthropic Messages route", () => {
|
||||||
expect(expectToolResult(prepared.body).content).toEqual([
|
expect(expectToolResult(prepared.body).content).toEqual([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||||
|
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -292,7 +294,7 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects non-image media in tool-result content with a clear error", () =>
|
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.prepare(
|
const error = yield* LLMClient.prepare(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
@ -756,7 +758,7 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("continues a conversation with user image content", () =>
|
it.effect("continues a conversation with user media content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
@ -766,6 +768,7 @@ describe("Anthropic Messages route", () => {
|
||||||
Message.user([
|
Message.user([
|
||||||
{ type: "text", text: "What is in this image?" },
|
{ type: "text", text: "What is in this image?" },
|
||||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||||
|
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
@ -781,6 +784,7 @@ describe("Anthropic Messages route", () => {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: "What is in this image?" },
|
{ type: "text", text: "What is in this image?" },
|
||||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||||
|
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -549,10 +549,12 @@ describe("Bedrock Converse route", () => {
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_doc",
|
id: "req_doc",
|
||||||
model,
|
model,
|
||||||
|
cache: "none",
|
||||||
messages: [
|
messages: [
|
||||||
Message.user([
|
Message.user([
|
||||||
|
{ type: "text", text: "Summarize these documents." },
|
||||||
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
|
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
|
||||||
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
|
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
@ -563,10 +565,9 @@ describe("Bedrock Converse route", () => {
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
// Filename round-trips when supplied.
|
{ text: "Summarize these documents." },
|
||||||
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
||||||
// Falls back to a stable placeholder when filename is missing.
|
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||||
{ document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -574,6 +575,96 @@ describe("Bedrock Converse route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("requires names for document media", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error.message).toContain("document media requires a filename")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("passes named document-only messages through for provider validation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
cache: "none",
|
||||||
|
messages: [
|
||||||
|
Message.user({
|
||||||
|
type: "media",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
data: "UERGREFUQQ==",
|
||||||
|
filename: "report.pdf",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lowers document media in tool results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "Read successfully" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: "report",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
toolResult: {
|
||||||
|
toolUseId: "call_1",
|
||||||
|
status: "success",
|
||||||
|
content: [
|
||||||
|
{ text: "Read successfully" },
|
||||||
|
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rejects unsupported image media types", () =>
|
it.effect("rejects unsupported image media types", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.prepare(
|
const error = yield* LLMClient.prepare(
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ describe("Gemini route", () => {
|
||||||
Message.user([
|
Message.user([
|
||||||
{ type: "text", text: "What is in this image?" },
|
{ type: "text", text: "What is in this image?" },
|
||||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||||
|
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
|
||||||
]),
|
]),
|
||||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||||
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||||
|
|
@ -81,7 +82,11 @@ describe("Gemini route", () => {
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
parts: [
|
||||||
|
{ text: "What is in this image?" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||||
|
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "model",
|
role: "model",
|
||||||
|
|
@ -90,7 +95,12 @@ describe("Gemini route", () => {
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: [
|
parts: [
|
||||||
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: '{"forecast":"sunny"}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -110,7 +120,7 @@ describe("Gemini route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("continues image tool results as inline vision input without base64 text", () =>
|
it.effect("continues media tool results as inline model input without base64 text", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
@ -125,6 +135,7 @@ describe("Gemini route", () => {
|
||||||
value: [
|
value: [
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||||
|
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -141,9 +152,12 @@ describe("Gemini route", () => {
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: "read",
|
name: "read",
|
||||||
response: { name: "read", content: "Image read successfully" },
|
response: { name: "read", content: "Image read successfully" },
|
||||||
|
parts: [
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||||
|
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
@ -174,8 +188,13 @@ describe("Gemini route", () => {
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: [
|
parts: [
|
||||||
{ functionResponse: { name: "read", response: { name: "read", content: "" } } },
|
{
|
||||||
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
|
functionResponse: {
|
||||||
|
name: "read",
|
||||||
|
response: { name: "read", content: "" },
|
||||||
|
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
@ -372,7 +391,10 @@ describe("Gemini route", () => {
|
||||||
parts: [
|
parts: [
|
||||||
{ text: "thinking", thought: true },
|
{ text: "thinking", thought: true },
|
||||||
{ text: "", thought: true, thoughtSignature: "thought_sig" },
|
{ text: "", thought: true, thoughtSignature: "thought_sig" },
|
||||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
{
|
||||||
|
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "tool_sig",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
finishReason: "STOP",
|
finishReason: "STOP",
|
||||||
|
|
@ -398,7 +420,10 @@ describe("Gemini route", () => {
|
||||||
id: "reasoning-0",
|
id: "reasoning-0",
|
||||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||||
})
|
})
|
||||||
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
expect(toolCall).toMatchObject({
|
||||||
|
id: "tool_0",
|
||||||
|
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||||
|
})
|
||||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||||
response.events.findIndex((event) => event.type === "tool-call"),
|
response.events.findIndex((event) => event.type === "tool-call"),
|
||||||
)
|
)
|
||||||
|
|
@ -416,6 +441,13 @@ describe("Gemini route", () => {
|
||||||
providerMetadata: toolCall?.providerMetadata,
|
providerMetadata: toolCall?.providerMetadata,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "tool_0",
|
||||||
|
name: "lookup",
|
||||||
|
result: "done",
|
||||||
|
resultType: "text",
|
||||||
|
providerMetadata: toolCall?.providerMetadata,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -424,7 +456,22 @@ describe("Gemini route", () => {
|
||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
|
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
|
||||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
{
|
||||||
|
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "tool_sig",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: "provider_call",
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "done" },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
@ -498,7 +545,7 @@ describe("Gemini route", () => {
|
||||||
content: {
|
content: {
|
||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
|
||||||
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -513,7 +560,13 @@ describe("Gemini route", () => {
|
||||||
).pipe(Effect.provide(fixedResponse(body)))
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
expect(response.toolCalls).toEqual([
|
expect(response.toolCalls).toEqual([
|
||||||
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "tool_0",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerMetadata: { google: { functionCallId: "tool_0" } },
|
||||||
|
},
|
||||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||||
])
|
])
|
||||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, LLMEvent, LLMResponse } from "../../src"
|
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
|
||||||
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
|
|
@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
name: "OpenRouter",
|
name: "OpenRouter",
|
||||||
model: OpenRouter.configure({
|
model: Model.update(
|
||||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
OpenRouter.configure({
|
||||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||||
}).model("anthropic/claude-sonnet-4.6"),
|
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||||
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
{ compatibility: { reasoningField: "reasoning" } },
|
||||||
|
),
|
||||||
requires: ["OPENROUTER_API_KEY"],
|
requires: ["OPENROUTER_API_KEY"],
|
||||||
cassette: "openrouter-reasoning",
|
cassette: "openrouter-reasoning",
|
||||||
structured: true,
|
structured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vercel AI Gateway",
|
name: "Vercel AI Gateway",
|
||||||
model: OpenAICompatible.configure({
|
model: Model.update(
|
||||||
provider: "vercel-ai-gateway",
|
OpenAICompatible.configure({
|
||||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
provider: "vercel-ai-gateway",
|
||||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||||
}).model("anthropic/claude-sonnet-4.6"),
|
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||||
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
{ compatibility: { reasoningField: "reasoning" } },
|
||||||
|
),
|
||||||
requires: ["AI_GATEWAY_API_KEY"],
|
requires: ["AI_GATEWAY_API_KEY"],
|
||||||
cassette: "vercel-ai-gateway-reasoning",
|
cassette: "vercel-ai-gateway-reasoning",
|
||||||
structured: true,
|
structured: true,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thinking",
|
||||||
|
providerMetadata: { openai: { reasoningField: "reasoning" } },
|
||||||
|
},
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
]),
|
||||||
|
Message.assistant("Done"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
|
||||||
|
{ role: "assistant", content: "Done", vendor_reasoning: "" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
|
||||||
|
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error.message).toContain("reserved field content")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps OpenAI provider options to Chat options", () =>
|
it.effect("maps OpenAI provider options to Chat options", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
|
@ -570,6 +609,35 @@ describe("OpenAI Chat route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||||
|
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { vendor_reasoning: "thinking" } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "vendor_reasoning" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model: custom, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
|
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const details = [
|
const details = [
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
import * as XAI from "../../src/providers/xai"
|
||||||
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
||||||
import * as ProviderShared from "../../src/protocols/shared"
|
import * as ProviderShared from "../../src/protocols/shared"
|
||||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
|
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
|
||||||
|
|
@ -16,6 +17,8 @@ const model = OpenAIResponses.route
|
||||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
.model({ id: "gpt-4.1-mini" })
|
.model({ id: "gpt-4.1-mini" })
|
||||||
|
|
||||||
|
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
|
||||||
|
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
id: "req_1",
|
id: "req_1",
|
||||||
model,
|
model,
|
||||||
|
|
@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects non-image media in tool-result content with a clear error", () =>
|
it.effect("lowers PDF tool-result content as structured input_file array", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
id: "req_tool_result_pdf",
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
resultType: "content",
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: "report.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||||
|
{
|
||||||
|
type: "input_file",
|
||||||
|
filename: "report.pdf",
|
||||||
|
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: xaiModel,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
resultType: "content",
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: "report.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||||
|
{
|
||||||
|
type: "input_file",
|
||||||
|
filename: "report.pdf",
|
||||||
|
file_data: "JVBERi0xLjQ=",
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.prepare(
|
const error = yield* LLMClient.prepare(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|
@ -1526,20 +1599,64 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("lowers user image content", () =>
|
it.effect("lowers user image and PDF content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_media",
|
id: "req_media",
|
||||||
model,
|
model,
|
||||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
|
messages: [
|
||||||
|
Message.user([
|
||||||
|
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||||
|
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
|
content: [
|
||||||
|
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
|
||||||
|
{
|
||||||
|
type: "input_file",
|
||||||
|
filename: "report.pdf",
|
||||||
|
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: xaiModel,
|
||||||
|
messages: [
|
||||||
|
Message.user({
|
||||||
|
type: "media",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
|
filename: "report.pdf",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "input_file",
|
||||||
|
filename: "report.pdf",
|
||||||
|
file_data: "JVBERi0xLjQ=",
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
@ -1551,11 +1668,11 @@ describe("OpenAI Responses route", () => {
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_media",
|
id: "req_media",
|
||||||
model,
|
model,
|
||||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
|
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.flip)
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
|
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
207
packages/ai/test/provider/pdf.recorded.test.ts
Normal file
207
packages/ai/test/provider/pdf.recorded.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Schema, Stream } from "effect"
|
||||||
|
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
|
||||||
|
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
|
||||||
|
import { LLMClient } from "../../src/route"
|
||||||
|
import { Tool } from "../../src/tool"
|
||||||
|
import { runTools } from "../lib/tool-runtime"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const CODE = "ORCHID-7391"
|
||||||
|
const PDF =
|
||||||
|
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
|
||||||
|
|
||||||
|
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
|
||||||
|
const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
|
||||||
|
const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
|
||||||
|
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||||
|
const bedrock = AmazonBedrock.configure({
|
||||||
|
apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
|
||||||
|
region: process.env.AWS_REGION ?? "us-east-1",
|
||||||
|
})
|
||||||
|
|
||||||
|
const targets: ReadonlyArray<{
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly provider: string
|
||||||
|
readonly protocol: string
|
||||||
|
readonly requires: string
|
||||||
|
readonly filename: string
|
||||||
|
readonly maxTokens: number
|
||||||
|
readonly model: Model
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "openai",
|
||||||
|
name: "OpenAI Responses gpt-4o-mini",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: "OPENAI_API_KEY",
|
||||||
|
filename: "verification.pdf",
|
||||||
|
maxTokens: 40,
|
||||||
|
model: openai.responses("gpt-4o-mini"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "anthropic",
|
||||||
|
name: "Anthropic Haiku 4.5",
|
||||||
|
provider: "anthropic",
|
||||||
|
protocol: "anthropic-messages",
|
||||||
|
requires: "ANTHROPIC_API_KEY",
|
||||||
|
filename: "verification.pdf",
|
||||||
|
maxTokens: 40,
|
||||||
|
model: anthropic.model("claude-haiku-4-5-20251001"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini",
|
||||||
|
name: "Gemini 3.5 Flash",
|
||||||
|
provider: "google",
|
||||||
|
protocol: "gemini",
|
||||||
|
requires: "GOOGLE_API_KEY",
|
||||||
|
filename: "verification.pdf",
|
||||||
|
maxTokens: 256,
|
||||||
|
model: google.model("gemini-3.5-flash"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "xai",
|
||||||
|
name: "xAI Grok 4.5",
|
||||||
|
provider: "xai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: "XAI_API_KEY",
|
||||||
|
filename: "verification.pdf",
|
||||||
|
maxTokens: 40,
|
||||||
|
model: xai.responses("grok-4.5"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bedrock",
|
||||||
|
name: "Bedrock Claude Haiku 4.5",
|
||||||
|
provider: "amazon-bedrock",
|
||||||
|
protocol: "bedrock-converse",
|
||||||
|
requires: "AWS_BEDROCK_API_KEY",
|
||||||
|
filename: "verification",
|
||||||
|
maxTokens: 40,
|
||||||
|
model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
|
||||||
|
const prompt = "Return only the verification code from the PDF."
|
||||||
|
const readPdf = ToolDefinition.make({
|
||||||
|
name: "read_pdf",
|
||||||
|
description: "Read the attached PDF.",
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
})
|
||||||
|
const readPdfRuntime = Tool.make({
|
||||||
|
description: readPdf.description,
|
||||||
|
parameters: Schema.Struct({ path: Schema.String }),
|
||||||
|
success: Schema.String,
|
||||||
|
execute: () => Effect.succeed("PDF read successfully"),
|
||||||
|
toModelOutput: () => [
|
||||||
|
{ type: "text", text: "PDF read successfully" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: `data:application/pdf;base64,${PDF}`,
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: "verification.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const expectCode = (response: LLMResponse) => {
|
||||||
|
expect(response.finishReason).toBe("stop")
|
||||||
|
expect(response.text.toUpperCase()).toContain(CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PDF recorded", () => {
|
||||||
|
for (const target of targets) {
|
||||||
|
recorded.effect.with(
|
||||||
|
`reads a user PDF with ${target.name}`,
|
||||||
|
{
|
||||||
|
id: `${target.id}-user-input`,
|
||||||
|
provider: target.provider,
|
||||||
|
protocol: target.protocol,
|
||||||
|
requires: [target.requires],
|
||||||
|
tags: ["user-input"],
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expectCode(
|
||||||
|
yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
id: `recorded_pdf_${target.id}_user_input`,
|
||||||
|
model: target.model,
|
||||||
|
cache: "none",
|
||||||
|
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||||
|
messages: [
|
||||||
|
Message.user([
|
||||||
|
{ type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with(
|
||||||
|
`reads a PDF tool result with ${target.name}`,
|
||||||
|
{
|
||||||
|
id: `${target.id}-tool-result`,
|
||||||
|
provider: target.provider,
|
||||||
|
protocol: target.protocol,
|
||||||
|
requires: [target.requires],
|
||||||
|
tags: ["tool", "tool-result"],
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (target.id === "gemini") {
|
||||||
|
const events = Array.from(
|
||||||
|
yield* runTools({
|
||||||
|
request: LLM.request({
|
||||||
|
id: "recorded_pdf_gemini_tool_result",
|
||||||
|
model: target.model,
|
||||||
|
system:
|
||||||
|
"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
|
||||||
|
prompt: "Use read_pdf with path verification.pdf and return the verification code.",
|
||||||
|
cache: "none",
|
||||||
|
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||||
|
}),
|
||||||
|
tools: { read_pdf: readPdfRuntime },
|
||||||
|
}).pipe(Stream.runCollect),
|
||||||
|
)
|
||||||
|
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||||
|
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expectCode(
|
||||||
|
yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
id: `recorded_pdf_${target.id}_tool_result`,
|
||||||
|
model: target.model,
|
||||||
|
system: "Read the PDF returned by the tool and follow the user's response format exactly.",
|
||||||
|
cache: "none",
|
||||||
|
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||||
|
messages: [
|
||||||
|
Message.user(prompt),
|
||||||
|
Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_pdf_1",
|
||||||
|
name: readPdf.name,
|
||||||
|
resultType: "content",
|
||||||
|
result: [
|
||||||
|
{ type: "text", text: "PDF read successfully" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: `data:application/pdf;base64,${PDF}`,
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: target.filename,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tools: [readPdf],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -183,6 +183,51 @@ describe("LLMClient tools", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves provider metadata on dispatched tool results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tool = Tool.make({
|
||||||
|
description: "Return text.",
|
||||||
|
parameters: Schema.Struct({}),
|
||||||
|
success: Schema.String,
|
||||||
|
execute: () => Effect.succeed("hello"),
|
||||||
|
})
|
||||||
|
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||||
|
const dispatched = yield* ToolRuntime.dispatch(
|
||||||
|
{ tool },
|
||||||
|
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(dispatched.events).toEqual([
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: "call_1",
|
||||||
|
name: "tool",
|
||||||
|
result: { type: "text", value: "hello" },
|
||||||
|
output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
|
||||||
|
providerMetadata,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const failed = yield* ToolRuntime.dispatch(
|
||||||
|
{},
|
||||||
|
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
|
||||||
|
)
|
||||||
|
expect(failed.events).toEqual([
|
||||||
|
LLMEvent.toolError({
|
||||||
|
id: "call_2",
|
||||||
|
name: "missing",
|
||||||
|
message: "Unknown tool: missing",
|
||||||
|
providerMetadata,
|
||||||
|
}),
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: "call_2",
|
||||||
|
name: "missing",
|
||||||
|
result: { type: "error", value: "Unknown tool: missing" },
|
||||||
|
providerMetadata,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses the narrow default projection for encoded typed success", () =>
|
it.effect("uses the narrow default projection for encoded typed success", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const text = Tool.make({
|
const text = Tool.make({
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
"bin"
|
"bin"
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./daemon": "./src/daemon.ts",
|
|
||||||
"./run": "./src/run/index.ts",
|
"./run": "./src/run/index.ts",
|
||||||
"./server-process": "./src/server-process.ts"
|
"./server-process": "./src/server-process.ts"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { $ } from "bun"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||||
|
|
||||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
@ -81,3 +82,10 @@ await publishDistribution({
|
||||||
binary: "opencode2-node",
|
binary: "opencode2-node",
|
||||||
packagePrefix: "@opencode-ai/cli-node-",
|
packagePrefix: "@opencode-ai/cli-node-",
|
||||||
})
|
})
|
||||||
|
await UpdateArtifact.publish({
|
||||||
|
channel: Script.channel,
|
||||||
|
name: "cli",
|
||||||
|
distribution: "npm",
|
||||||
|
version: Script.version,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,8 @@ export async function streamTurn(input: {
|
||||||
toolCallId: event.data.callID,
|
toolCallId: event.data.callID,
|
||||||
toolName: current.name,
|
toolName: current.name,
|
||||||
input: current.input,
|
input: current.input,
|
||||||
structured: current.structured,
|
structured: event.data.metadata ?? current.structured,
|
||||||
content: current.content,
|
content: event.data.content ?? current.content,
|
||||||
error: event.data.error.message,
|
error: event.data.error.message,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ import type {
|
||||||
SetSessionModeRequest,
|
SetSessionModeRequest,
|
||||||
SetSessionModeResponse,
|
SetSessionModeResponse,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../version"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||||
import { promptContentToParts } from "./content"
|
import { promptContentToParts } from "./content"
|
||||||
|
|
@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||||
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
||||||
},
|
},
|
||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: { name: "OpenCode", version: InstallationVersion },
|
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
authenticate: async (params) => {
|
authenticate: async (params) => {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
Flag.withDescription("Session ID to continue"),
|
Flag.withDescription("Session ID to continue"),
|
||||||
Flag.optional,
|
Flag.optional,
|
||||||
),
|
),
|
||||||
|
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||||
},
|
},
|
||||||
commands: [
|
commands: [
|
||||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
import { UpdatePreflight } from "../../services/update-preflight"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
const runPromise = Effect.runPromiseWith(context)
|
const runPromise = Effect.runPromiseWith(context)
|
||||||
const service = server.service
|
const service = server.service
|
||||||
yield* run({
|
yield* run({
|
||||||
|
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||||
server: {
|
server: {
|
||||||
endpoint: server.endpoint,
|
endpoint: server.endpoint,
|
||||||
service: service
|
service: service
|
||||||
|
|
@ -53,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: {
|
||||||
|
continue: input.continue,
|
||||||
|
sessionID: Option.getOrUndefined(input.session),
|
||||||
|
prompt: Option.getOrUndefined(input.prompt),
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
path: config.path,
|
path: config.path,
|
||||||
get: () => runPromise(config.get()),
|
get: () => runPromise(config.get()),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Commands } from "./commands/commands"
|
import { Commands } from "./commands/commands"
|
||||||
import { Runtime } from "./framework/runtime"
|
import { Runtime } from "./framework/runtime"
|
||||||
import { Observability } from "@opencode-ai/util/observability"
|
import { Observability } from "@opencode-ai/util/observability"
|
||||||
import { Client } from "@opencode-ai/util/client"
|
|
||||||
import { Updater } from "./services/updater"
|
import { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
|
@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
|
||||||
})
|
})
|
||||||
|
|
||||||
Effect.logInfo("cli starting", {
|
Effect.logInfo("cli starting", {
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
channel: InstallationChannel,
|
channel: OPENCODE_CHANNEL,
|
||||||
local: InstallationLocal,
|
local: OPENCODE_LOCAL,
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Config.layer),
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
|
|
@ -74,7 +73,10 @@ Effect.logInfo("cli starting", {
|
||||||
Observability.layer({
|
Observability.layer({
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||||
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
|
client: process.env.OPENCODE_CLIENT ?? "cli",
|
||||||
|
version: OPENCODE_VERSION,
|
||||||
|
channel: OPENCODE_CHANNEL,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
import { setTimeout } from "node:timers/promises"
|
import { setTimeout } from "node:timers/promises"
|
||||||
import { waitForCatalogReady } from "./services/catalog"
|
|
||||||
import { readStdin } from "./util/io"
|
import { readStdin } from "./util/io"
|
||||||
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||||
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
||||||
|
|
@ -214,63 +213,7 @@ function parseModel(value?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||||
return async (input) => {
|
return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent })
|
||||||
if (input.model)
|
|
||||||
await waitForCatalogReady({
|
|
||||||
sdk: input.client,
|
|
||||||
directory: input.location.directory,
|
|
||||||
workspace: input.location.workspaceID,
|
|
||||||
model: { providerID: input.model.providerID, modelID: input.model.id },
|
|
||||||
signal: input.signal,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
model: input.model,
|
|
||||||
agent: requestedAgent
|
|
||||||
? await validateAgent(
|
|
||||||
input.client,
|
|
||||||
input.location.directory,
|
|
||||||
input.location.workspaceID,
|
|
||||||
requestedAgent,
|
|
||||||
input.signal,
|
|
||||||
)
|
|
||||||
: input.agent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateAgent(
|
|
||||||
sdk: OpenCodeClient,
|
|
||||||
directory: string,
|
|
||||||
workspace: string | undefined,
|
|
||||||
name?: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
if (!name) return
|
|
||||||
const deadline = Date.now() + 5_000
|
|
||||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
|
||||||
while (Date.now() < deadline && !signal?.aborted) {
|
|
||||||
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
|
|
||||||
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
const agent = agents?.data.find((item) => item.id === name)
|
|
||||||
if (agent?.mode === "subagent") {
|
|
||||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (agent) return name
|
|
||||||
await setTimeout(25, undefined, { signal }).catch(() => {})
|
|
||||||
}
|
|
||||||
if (signal?.aborted) return
|
|
||||||
if (!agents) {
|
|
||||||
warning("failed to list agents. Falling back to default agent")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function warning(message: string) {
|
|
||||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message: string): never {
|
function fail(message: string): never {
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
}
|
}
|
||||||
if (!promoted && event.type === "session.execution.failed") {
|
if (!promoted && event.type === "session.execution.failed") {
|
||||||
prePromotionError = event.data.error
|
prePromotionError = event.data.error
|
||||||
|
if (finalizing) return
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!promoted &&
|
||||||
|
finalizing &&
|
||||||
|
(event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted")
|
||||||
|
)
|
||||||
|
return
|
||||||
if (!promoted) continue
|
if (!promoted) continue
|
||||||
if (finalizing) continue
|
if (finalizing && !event.type.startsWith("session.execution.")) continue
|
||||||
|
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
const part = {
|
const part = {
|
||||||
|
|
@ -391,6 +398,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||||
const current = tools.get(key) ?? fallbackTool(event)
|
const current = tools.get(key) ?? fallbackTool(event)
|
||||||
const error = event.data.error.message
|
const error = event.data.error.message
|
||||||
|
const structured = event.data.metadata ?? current.structured
|
||||||
|
const content = event.data.content ?? current.content
|
||||||
const tool: SessionMessageAssistantTool = {
|
const tool: SessionMessageAssistantTool = {
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
|
|
@ -401,8 +410,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
structured: current.structured,
|
structured,
|
||||||
content: current.content,
|
content,
|
||||||
error: event.data.error,
|
error: event.data.error,
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
|
|
@ -432,14 +441,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
renderedTools.add(key)
|
renderedTools.add(key)
|
||||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
if (toolOutputText(current.tool, current.content).trim())
|
if (toolOutputText(current.tool, content).trim())
|
||||||
await input.renderTool({
|
await input.renderTool({
|
||||||
...tool,
|
...tool,
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
structured: current.structured,
|
structured,
|
||||||
content: current.content,
|
content,
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -618,7 +627,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return projected.found
|
return {
|
||||||
|
found: projected.found,
|
||||||
|
responded: projected.messages.some((message) => message.type === "assistant"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const interrupt = () => {
|
const interrupt = () => {
|
||||||
|
|
@ -708,9 +720,18 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||||
const waiting = input.client.session.wait({ sessionID: input.sessionID })
|
const waiting = input.client.session.wait({ sessionID: input.sessionID })
|
||||||
await Promise.race([waiting, completed.then(() => waiting)])
|
await Promise.race([waiting, completed.then(() => waiting)])
|
||||||
finalizing = true
|
finalizing = true
|
||||||
controller.abort()
|
const projected = await reconcile()
|
||||||
const found = await reconcile()
|
if (
|
||||||
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
|
!projected.responded &&
|
||||||
|
!interrupted &&
|
||||||
|
!permissionRejected &&
|
||||||
|
!formCancelled &&
|
||||||
|
!emittedError &&
|
||||||
|
!prePromotionError
|
||||||
|
) {
|
||||||
|
await completed
|
||||||
|
}
|
||||||
|
if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
|
||||||
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
|
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { readStdin } from "../util/io"
|
import { readStdin } from "../util/io"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
import { ServerConnection } from "../services/server-connection"
|
||||||
import { waitForCatalogReady } from "../services/catalog"
|
|
||||||
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
|
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
|
||||||
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
|
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
|
|
@ -95,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||||
prepare: async (next) => {
|
prepare: async (next) => {
|
||||||
const selected =
|
const selected =
|
||||||
next.model ??
|
next.model ??
|
||||||
(await client.model
|
(options.variant
|
||||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
? await client.model
|
||||||
.then((result) => result.data))
|
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
: undefined)
|
||||||
const model = selected
|
const model = selected
|
||||||
? {
|
? {
|
||||||
providerID: selected.providerID,
|
providerID: selected.providerID,
|
||||||
|
|
@ -107,25 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||||
: undefined
|
: undefined
|
||||||
if ((options.variant ?? explicit?.variant) && !model)
|
if ((options.variant ?? explicit?.variant) && !model)
|
||||||
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
||||||
if (model) {
|
return { model, agent: next.agent }
|
||||||
await waitForCatalogReady({
|
|
||||||
sdk: client,
|
|
||||||
directory: next.location.directory,
|
|
||||||
workspace: next.location.workspaceID,
|
|
||||||
model: { providerID: model.providerID, modelID: model.id },
|
|
||||||
})
|
|
||||||
const available = await client.model.list({
|
|
||||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
|
||||||
})
|
|
||||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
|
|
||||||
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
agent: input.agent
|
|
||||||
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
|
|
||||||
: next.agent,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (!(error instanceof RunTargetError)) throw error
|
if (!(error instanceof RunTargetError)) throw error
|
||||||
|
|
@ -190,28 +173,6 @@ export function parseRunModel(value?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
|
|
||||||
if (!name) return
|
|
||||||
const agents = await client.agent
|
|
||||||
.list({ location: { directory, workspace } })
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (!agents) {
|
|
||||||
warning("failed to list agents. Falling back to default agent")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const agent = agents.find((item) => item.id === name)
|
|
||||||
if (!agent) {
|
|
||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (agent.mode === "subagent") {
|
|
||||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
||||||
const file = path.resolve(directory, input)
|
const file = path.resolve(directory, input)
|
||||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import { randomBytes, randomUUID } from "node:crypto"
|
import { randomBytes, randomUUID } from "node:crypto"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||||
const instanceID = randomUUID()
|
const instanceID = randomUUID()
|
||||||
const server = yield* start(
|
const server = yield* start(
|
||||||
{
|
{
|
||||||
client: process.env.OPENCODE_CLIENT ?? "cli",
|
app: {
|
||||||
|
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||||
|
version: OPENCODE_VERSION,
|
||||||
|
channel: OPENCODE_CHANNEL,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
port,
|
port,
|
||||||
password,
|
password,
|
||||||
|
|
@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||||
database: {
|
database: {
|
||||||
path:
|
path:
|
||||||
process.env.OPENCODE_DB ??
|
process.env.OPENCODE_DB ??
|
||||||
(["latest", "beta", "prod"].includes(InstallationChannel) ||
|
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
|
||||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||||
? "opencode.db"
|
? "opencode.db"
|
||||||
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
url: process.env.OPENCODE_MODELS_URL,
|
url: process.env.OPENCODE_MODELS_URL,
|
||||||
|
|
@ -173,7 +177,7 @@ const register = Effect.fnUntraced(function* (
|
||||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||||
const info = {
|
const info = {
|
||||||
id,
|
id,
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
url: HttpServer.formatAddress(address),
|
url: HttpServer.formatAddress(address),
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
password,
|
password,
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
|
|
||||||
|
|
||||||
// Location plugins initialize asynchronously, so explicit model selection must
|
|
||||||
// wait for that exact model before prompt admission. The execution path owns
|
|
||||||
// the authoritative error if readiness times out.
|
|
||||||
export async function waitForCatalogReady(input: {
|
|
||||||
sdk: OpenCodeClient
|
|
||||||
directory: string
|
|
||||||
workspace?: string
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
timeoutMs?: number
|
|
||||||
signal?: AbortSignal
|
|
||||||
}) {
|
|
||||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
|
||||||
while (Date.now() < deadline && !input.signal?.aborted) {
|
|
||||||
const models = await input.sdk.model
|
|
||||||
.list(
|
|
||||||
{ location: { directory: input.directory, workspace: input.workspace } },
|
|
||||||
{ signal: input.signal },
|
|
||||||
)
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch((error) => {
|
|
||||||
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
|
||||||
await wait(25, input.signal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(delay: number, signal?: AbortSignal) {
|
|
||||||
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
|
|
||||||
if (signal.aborted) return Promise.resolve()
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal?.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
|
||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../version"
|
||||||
import { Effect, Redacted } from "effect"
|
import { Effect, Redacted } from "effect"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { ServiceConfig } from "./service-config"
|
import { ServiceConfig } from "./service-config"
|
||||||
|
|
@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||||
catch: (cause) => connectError(endpoint, cause),
|
catch: (cause) => connectError(endpoint, cause),
|
||||||
})
|
})
|
||||||
if (health.version !== InstallationVersion)
|
if (health.version !== OPENCODE_VERSION)
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
|
||||||
)
|
)
|
||||||
return { endpoint } satisfies Resolved
|
return { endpoint } satisfies Resolved
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
|
||||||
import { Hash } from "@opencode-ai/util/hash"
|
import { Hash } from "@opencode-ai/util/hash"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
|
|
@ -24,26 +24,26 @@ type Key = (typeof keys)[number]
|
||||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
|
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
|
||||||
|
|
||||||
export function filename(channel = InstallationChannel) {
|
export function filename(channel = OPENCODE_CHANNEL) {
|
||||||
if (channel === "latest" || channel === "next") return "service.json"
|
if (channel === "latest" || channel === "next") return "service.json"
|
||||||
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultPort(channel = InstallationChannel) {
|
export function defaultPort(channel = OPENCODE_CHANNEL) {
|
||||||
if (channel === "latest" || channel === "next") return 0xc0de
|
if (channel === "latest" || channel === "next") return 0xc0de
|
||||||
if (channel === "local") return 0xc0df
|
if (channel === "local") return 0xc0df
|
||||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function legacyFilename(channel = InstallationChannel) {
|
export function legacyFilename(channel = OPENCODE_CHANNEL) {
|
||||||
if (channel === "latest" || channel === "local") return
|
if (channel === "latest" || channel === "local") return
|
||||||
return `service-${Hash.fast(channel)}.json`
|
return `service-${Hash.fast(channel)}.json`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function versionBelongsToChannel(
|
export function versionBelongsToChannel(
|
||||||
version: string | undefined,
|
version: string | undefined,
|
||||||
channel = InstallationChannel,
|
channel = OPENCODE_CHANNEL,
|
||||||
installedVersion = InstallationVersion,
|
installedVersion = OPENCODE_VERSION,
|
||||||
) {
|
) {
|
||||||
if (version === undefined) return false
|
if (version === undefined) return false
|
||||||
if (version === installedVersion) return true
|
if (version === installedVersion) return true
|
||||||
|
|
@ -55,8 +55,8 @@ export function versionBelongsToChannel(
|
||||||
export const migrateRegistration = Effect.fnUntraced(function* (
|
export const migrateRegistration = Effect.fnUntraced(function* (
|
||||||
legacy: string,
|
legacy: string,
|
||||||
file: string,
|
file: string,
|
||||||
channel = InstallationChannel,
|
channel = OPENCODE_CHANNEL,
|
||||||
installedVersion = InstallationVersion,
|
installedVersion = OPENCODE_VERSION,
|
||||||
) {
|
) {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
|
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
|
||||||
|
|
@ -92,7 +92,7 @@ const paths = Effect.gen(function* () {
|
||||||
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
|
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
|
||||||
legacyRegistrationFiles: [
|
legacyRegistrationFiles: [
|
||||||
...(legacy ? [path.join(global.state, legacy)] : []),
|
...(legacy ? [path.join(global.state, legacy)] : []),
|
||||||
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
|
...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
|
||||||
],
|
],
|
||||||
configFile: path.join(global.config, name),
|
configFile: path.join(global.config, name),
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () {
|
||||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
command: [...selfCommand(), "serve", "--service"],
|
command: [...selfCommand(), "serve", "--service"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// version-mismatched background service before the TUI attaches.
|
// version-mismatched background service before the TUI attaches.
|
||||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../version"
|
||||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||||
import { go } from "@opencode-ai/tui/logo"
|
import { go } from "@opencode-ai/tui/logo"
|
||||||
|
|
@ -356,12 +356,12 @@ function UpdateFooter(props: {
|
||||||
] as const)
|
] as const)
|
||||||
: []),
|
: []),
|
||||||
["to", colors.muted],
|
["to", colors.muted],
|
||||||
[InstallationVersion, colors.accent],
|
[OPENCODE_VERSION, colors.accent],
|
||||||
)
|
)
|
||||||
const completedHeader = phrase(
|
const completedHeader = phrase(
|
||||||
["OpenCode", colors.muted, true],
|
["OpenCode", colors.muted, true],
|
||||||
["updated to", colors.muted],
|
["updated to", colors.muted],
|
||||||
[InstallationVersion, colors.accent],
|
[OPENCODE_VERSION, colors.accent],
|
||||||
)
|
)
|
||||||
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
||||||
const outcomeStatus = () =>
|
const outcomeStatus = () =>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { AppProcess } from "@opencode-ai/util/process"
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
import {
|
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||||
InstallationChannel,
|
|
||||||
InstallationLocal,
|
|
||||||
InstallationVersion,
|
|
||||||
} from "@opencode-ai/util/installation/version"
|
|
||||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
|
|
@ -52,7 +48,7 @@ export const layer = Layer.effect(
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const appProcess = yield* AppProcess.Service
|
const appProcess = yield* AppProcess.Service
|
||||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
|
||||||
const readPolicy = Effect.fnUntraced(function* () {
|
const readPolicy = Effect.fnUntraced(function* () {
|
||||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||||
|
|
@ -99,8 +95,8 @@ export const layer = Layer.effect(
|
||||||
const response = yield* Effect.tryPromise({
|
const response = yield* Effect.tryPromise({
|
||||||
try: () =>
|
try: () =>
|
||||||
fetch(
|
fetch(
|
||||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`,
|
||||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
{ headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
|
||||||
),
|
),
|
||||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||||
})
|
})
|
||||||
|
|
@ -138,13 +134,13 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
const check = Effect.fn("cli.updater.check")(function* () {
|
const check = Effect.fn("cli.updater.check")(function* () {
|
||||||
if (
|
if (
|
||||||
InstallationLocal ||
|
OPENCODE_LOCAL ||
|
||||||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
|
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
|
||||||
)
|
)
|
||||||
return yield* Effect.logInfo("update check skipped", {
|
return yield* Effect.logInfo("update check skipped", {
|
||||||
reason: InstallationLocal ? "local-install" : "disabled",
|
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
channel: InstallationChannel,
|
channel: OPENCODE_CHANNEL,
|
||||||
})
|
})
|
||||||
const policy = yield* readPolicy()
|
const policy = yield* readPolicy()
|
||||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||||
|
|
@ -152,15 +148,15 @@ export const layer = Layer.effect(
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const version = yield* latest()
|
const version = yield* latest()
|
||||||
yield* Effect.logInfo("update check", {
|
yield* Effect.logInfo("update check", {
|
||||||
current: InstallationVersion,
|
current: OPENCODE_VERSION,
|
||||||
latest: version,
|
latest: version,
|
||||||
})
|
})
|
||||||
const next = action(InstallationVersion, version, policy)
|
const next = action(OPENCODE_VERSION, version, policy)
|
||||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||||
const detected = yield* method()
|
const detected = yield* method()
|
||||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||||
yield* upgrade(detected, version)
|
yield* upgrade(detected, version)
|
||||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||||
})
|
})
|
||||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: {
|
||||||
session,
|
session,
|
||||||
location,
|
location,
|
||||||
model: prepared.model,
|
model: prepared.model,
|
||||||
agent: prepared.agent,
|
agent: prepared.agent ?? session.agent,
|
||||||
resume: selected !== undefined,
|
resume: selected !== undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
packages/cli/src/version.ts
Normal file
8
packages/cli/src/version.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
declare const OPENCODE_VERSION: string
|
||||||
|
declare const OPENCODE_CHANNEL: string
|
||||||
|
|
||||||
|
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
|
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
|
|
||||||
|
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
|
||||||
|
export const OPENCODE_LOCAL = channel === "local"
|
||||||
|
|
@ -214,7 +214,7 @@ describe("acp event behavior", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
send(
|
send(
|
||||||
durableEvent("session.tool.progress", {
|
ephemeralEvent("session.tool.progress", {
|
||||||
sessionID: "ses_tools",
|
sessionID: "ses_tools",
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_ok",
|
callID: "call_ok",
|
||||||
|
|
@ -251,7 +251,7 @@ describe("acp event behavior", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
send(
|
send(
|
||||||
durableEvent("session.tool.progress", {
|
ephemeralEvent("session.tool.progress", {
|
||||||
sessionID: "ses_tools",
|
sessionID: "ses_tools",
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_fail",
|
callID: "call_fail",
|
||||||
|
|
@ -265,6 +265,8 @@ describe("acp event behavior", () => {
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_fail",
|
callID: "call_fail",
|
||||||
error: { type: "tool.error", message: "not found" },
|
error: { type: "tool.error", message: "not found" },
|
||||||
|
metadata: { bytes: 0 },
|
||||||
|
content: [{ type: "text", text: "opening" }],
|
||||||
executed: true,
|
executed: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||||
const service = yield* Config.Service
|
const service = yield* Config.Service
|
||||||
return yield* service.update((draft) => {
|
return yield* service.update((draft) => {
|
||||||
draft.prompt = { paste: "compact" }
|
draft.prompt = { paste: "compact" }
|
||||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
|
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
animations: true,
|
animations: true,
|
||||||
prompt: { paste: "compact" },
|
prompt: { paste: "compact" },
|
||||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
|
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||||
})
|
})
|
||||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,30 @@ export default defineScript({
|
||||||
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
||||||
const session = `mini-stage2-${process.pid}`
|
const session = `mini-stage2-${process.pid}`
|
||||||
const snapshots = path.join(artifacts, "mini-stage2")
|
const snapshots = path.join(artifacts, "mini-stage2")
|
||||||
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
|
const explicitDirectory = path.join(artifacts, "explicit-model")
|
||||||
|
yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
|
||||||
|
/** @param {string} directory @param {string | undefined} model */
|
||||||
|
const mini = (directory, model) => [
|
||||||
|
"env",
|
||||||
|
`PWD=${directory}`,
|
||||||
|
`OPENCODE_PASSWORD=${registration.password}`,
|
||||||
|
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||||
|
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||||
|
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||||
|
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||||
|
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||||
|
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||||
|
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||||
|
"OPENCODE_DIRECT_TRACE=1",
|
||||||
|
process.execPath,
|
||||||
|
"--conditions=browser",
|
||||||
|
`--preload=${preload}`,
|
||||||
|
path.join(root, "packages/cli/src/index.ts"),
|
||||||
|
"mini",
|
||||||
|
"--server",
|
||||||
|
registration.url,
|
||||||
|
...(model ? ["--model", model] : []),
|
||||||
|
]
|
||||||
|
|
||||||
yield* llm.queue(
|
yield* llm.queue(
|
||||||
Llm.toolCall({
|
Llm.toolCall({
|
||||||
|
|
@ -42,26 +65,7 @@ export default defineScript({
|
||||||
"-y",
|
"-y",
|
||||||
"30",
|
"30",
|
||||||
"--",
|
"--",
|
||||||
"env",
|
...mini(path.join(artifacts, "files"), undefined),
|
||||||
`PWD=${path.join(artifacts, "files")}`,
|
|
||||||
`OPENCODE_PASSWORD=${registration.password}`,
|
|
||||||
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
|
||||||
`OPENCODE_TEST_HOME=${artifacts}`,
|
|
||||||
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
|
||||||
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
|
||||||
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
|
||||||
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
|
||||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
|
||||||
"OPENCODE_DIRECT_TRACE=1",
|
|
||||||
process.execPath,
|
|
||||||
"--conditions=browser",
|
|
||||||
`--preload=${preload}`,
|
|
||||||
path.join(root, "packages/cli/src/index.ts"),
|
|
||||||
"mini",
|
|
||||||
"--server",
|
|
||||||
registration.url,
|
|
||||||
"--model",
|
|
||||||
"simulation/gpt-sim-model",
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -72,7 +76,16 @@ export default defineScript({
|
||||||
if (first.includes("drive mini response complete"))
|
if (first.includes("drive mini response complete"))
|
||||||
throw new Error("response rendered before prompt submission")
|
throw new Error("response rendered before prompt submission")
|
||||||
|
|
||||||
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
|
||||||
|
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
|
||||||
|
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||||
|
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
|
||||||
|
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||||
|
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
|
||||||
yield* Effect.sleep(100)
|
yield* Effect.sleep(100)
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||||
|
|
@ -134,7 +147,7 @@ export default defineScript({
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||||
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
|
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||||
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
|
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
|
||||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
|
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||||
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
|
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
|
||||||
|
|
@ -144,7 +157,7 @@ export default defineScript({
|
||||||
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
||||||
})
|
})
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||||
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
|
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||||
yield* Effect.promise(() => waitForDeadPane(session))
|
yield* Effect.promise(() => waitForDeadPane(session))
|
||||||
const status = yield* Effect.promise(() => paneDeadStatus(session))
|
const status = yield* Effect.promise(() => paneDeadStatus(session))
|
||||||
|
|
@ -153,6 +166,75 @@ export default defineScript({
|
||||||
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
||||||
throw new Error("Mini exit splash was not rendered before teardown")
|
throw new Error("Mini exit splash was not rendered before teardown")
|
||||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
|
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
|
||||||
|
|
||||||
|
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
tmux([
|
||||||
|
"respawn-pane",
|
||||||
|
"-k",
|
||||||
|
"-t",
|
||||||
|
session,
|
||||||
|
"--",
|
||||||
|
...mini(explicitDirectory, "simulation/gpt-sim-model"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||||
|
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||||
|
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||||
|
yield* Effect.promise(() => waitForDeadPane(session))
|
||||||
|
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
|
||||||
|
throw new Error("Explicit-model Mini did not exit cleanly")
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
for (const failure of [
|
||||||
|
{
|
||||||
|
args: ["--model", "simulation/definitely-missing"],
|
||||||
|
capture: "08-unavailable-model.txt",
|
||||||
|
expected: "Model unavailable: simulation/definitely-missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: ["--agent", "definitely-missing"],
|
||||||
|
capture: "09-unavailable-agent.txt",
|
||||||
|
expected: 'Agent not found: "definitely-missing"',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
path.join(root, "packages/cli/src/index.ts"),
|
||||||
|
"run",
|
||||||
|
"--server",
|
||||||
|
registration.url,
|
||||||
|
...failure.args,
|
||||||
|
"optimistic selection check",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: path.join(root, "packages/cli"),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PWD: path.join(artifacts, "files"),
|
||||||
|
OPENCODE_PASSWORD: registration.password,
|
||||||
|
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
|
||||||
|
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
|
||||||
|
if (!stderr.includes(failure.expected))
|
||||||
|
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
|
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
|
||||||
|
|
@ -188,6 +270,19 @@ function captureVisiblePane(session) {
|
||||||
return tmux(["capture-pane", "-p", "-t", session])
|
return tmux(["capture-pane", "-p", "-t", session])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} session @param {string} text @param {number} [timeout] */
|
||||||
|
async function waitForVisiblePane(session, text, timeout = 5_000) {
|
||||||
|
const deadline = Date.now() + timeout
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
last = await captureVisiblePane(session)
|
||||||
|
if (last.includes(text)) return last
|
||||||
|
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} session */
|
/** @param {string} session */
|
||||||
async function paneAlive(session) {
|
async function paneAlive(session) {
|
||||||
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ describe("CLI frontend import boundaries", () => {
|
||||||
test("exposes only the intentional package entrypoints", async () => {
|
test("exposes only the intentional package entrypoints", async () => {
|
||||||
const run = await import("@opencode-ai/cli/run")
|
const run = await import("@opencode-ai/cli/run")
|
||||||
const mini = await import("@opencode-ai/tui/mini")
|
const mini = await import("@opencode-ai/tui/mini")
|
||||||
|
const tool = await import("@opencode-ai/tui/mini/tool")
|
||||||
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
||||||
|
|
||||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||||
|
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
|
||||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../src/version"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||||
|
|
@ -31,14 +31,14 @@ describe("mini command", () => {
|
||||||
const initial = Bun.serve({
|
const initial = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch() {
|
fetch() {
|
||||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const replacement = Bun.serve({
|
const replacement = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch(request) {
|
fetch(request) {
|
||||||
authorization.push(request.headers.get("authorization"))
|
authorization.push(request.headers.get("authorization"))
|
||||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -121,11 +121,12 @@ describe("mini command", () => {
|
||||||
expect(result.stdout).toContain("run Run OpenCode with a message")
|
expect(result.stdout).toContain("run Run OpenCode with a message")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("exposes run without legacy attach or command modes", async () => {
|
test("exposes run without legacy interactive, attach, or command modes", async () => {
|
||||||
const result = await cli(["run", "--help"])
|
const result = await cli(["run", "--help"])
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
expect(result.stdout).toContain("--server string")
|
expect(result.stdout).toContain("--server string")
|
||||||
|
expect(result.stdout).not.toContain("--interactive")
|
||||||
expect(result.stdout).not.toContain("--variant")
|
expect(result.stdout).not.toContain("--variant")
|
||||||
expect(result.stdout).not.toContain("--attach")
|
expect(result.stdout).not.toContain("--attach")
|
||||||
expect(result.stdout).not.toContain("--command")
|
expect(result.stdout).not.toContain("--command")
|
||||||
|
|
@ -138,32 +139,47 @@ describe("mini command", () => {
|
||||||
expect(result.stderr).not.toContain("You must provide a message")
|
expect(result.stderr).not.toContain("You must provide a message")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves a run failure exit code", async () => {
|
test("passes explicit selections to session creation without catalog preflight", async () => {
|
||||||
let modelRequests = 0
|
const requests: string[] = []
|
||||||
|
let session: unknown
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch(request) {
|
async fetch(request) {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
|
requests.push(url.pathname)
|
||||||
if (url.pathname === "/api/health")
|
if (url.pathname === "/api/health")
|
||||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||||
if (url.pathname === "/api/location")
|
if (url.pathname === "/api/location")
|
||||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||||
if (url.pathname === "/api/model") {
|
if (url.pathname === "/api/session") {
|
||||||
modelRequests++
|
session = await request.json()
|
||||||
return Response.json({
|
return new Response("boom", { status: 500 })
|
||||||
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
|
|
||||||
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return new Response(undefined, { status: 404 })
|
return new Response(undefined, { status: 404 })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
const result = await cli([
|
||||||
|
"run",
|
||||||
|
"--server",
|
||||||
|
server.url.toString(),
|
||||||
|
"--model",
|
||||||
|
"definitely/missing",
|
||||||
|
"--agent",
|
||||||
|
"definitely-missing",
|
||||||
|
"hi",
|
||||||
|
])
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1)
|
expect(result.exitCode).toBe(1)
|
||||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
expect(result.stderr).toContain("UnexpectedStatus")
|
||||||
|
expect(session).toMatchObject({
|
||||||
|
agent: "definitely-missing",
|
||||||
|
model: { providerID: "definitely", id: "missing" },
|
||||||
|
})
|
||||||
|
expect(requests).not.toContain("/api/model")
|
||||||
|
expect(requests).not.toContain("/api/agent")
|
||||||
|
expect(requests).not.toContain("/api/location/wait")
|
||||||
} finally {
|
} finally {
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +213,7 @@ describe("mini command", () => {
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
expect(result.stdout).toContain("--server string")
|
expect(result.stdout).toContain("--server string")
|
||||||
|
expect(result.stdout).toContain("--prompt string")
|
||||||
expect(result.stdout).not.toContain("SUBCOMMANDS")
|
expect(result.stdout).not.toContain("SUBCOMMANDS")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,6 @@ function failedTool(inputID: string): V2Event[] {
|
||||||
id: "evt_failed_tool_progress",
|
id: "evt_failed_tool_progress",
|
||||||
created: 3,
|
created: 3,
|
||||||
type: "session.tool.progress",
|
type: "session.tool.progress",
|
||||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_failed_tool",
|
assistantMessageID: "msg_failed_tool",
|
||||||
|
|
@ -149,6 +148,8 @@ function failedTool(inputID: string): V2Event[] {
|
||||||
assistantMessageID: "msg_failed_tool",
|
assistantMessageID: "msg_failed_tool",
|
||||||
callID: "call_failed_tool",
|
callID: "call_failed_tool",
|
||||||
error: { type: "unknown", message: "tool failed" },
|
error: { type: "unknown", message: "tool failed" },
|
||||||
|
metadata: { checkpoint: 1 },
|
||||||
|
content: [{ type: "text", text: "partial output" }],
|
||||||
executed: true,
|
executed: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -156,6 +157,53 @@ function failedTool(inputID: string): V2Event[] {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function successfulGrep(inputID: string): V2Event[] {
|
||||||
|
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
|
||||||
|
return [
|
||||||
|
prompted(inputID),
|
||||||
|
{
|
||||||
|
id: "evt_grep_input",
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_grep",
|
||||||
|
callID: "call_grep",
|
||||||
|
name: "grep",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_grep_called",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.called",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_grep",
|
||||||
|
callID: "call_grep",
|
||||||
|
input: { pattern: "needle" },
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "evt_grep_success",
|
||||||
|
created: 3,
|
||||||
|
type: "session.tool.success",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_grep",
|
||||||
|
callID: "call_grep",
|
||||||
|
structured: { matches: 2 },
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
executed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settled(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||||
async function run(input: {
|
async function run(input: {
|
||||||
|
|
@ -169,6 +217,7 @@ async function run(input: {
|
||||||
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||||
messages?: (inputID: string) => SessionMessageInfo[]
|
messages?: (inputID: string) => SessionMessageInfo[]
|
||||||
wait?: () => Promise<void>
|
wait?: () => Promise<void>
|
||||||
|
terminalDelay?: number
|
||||||
}) {
|
}) {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
|
|
@ -183,7 +232,10 @@ async function run(input: {
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
|
if (value.type.startsWith("session.execution.")) {
|
||||||
|
if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
|
||||||
|
setTimeout(wait.resolve, 0)
|
||||||
|
}
|
||||||
yield value
|
yield value
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -251,7 +303,7 @@ async function capture(input: Parameters<typeof run>[0]) {
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await run(input)
|
await run(input)
|
||||||
return { stdout: stdout.join(""), stderr: stderr.join("") }
|
return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode }
|
||||||
} finally {
|
} finally {
|
||||||
process.exitCode = exitCode ?? 0
|
process.exitCode = exitCode ?? 0
|
||||||
stdoutWrite.mockRestore()
|
stdoutWrite.mockRestore()
|
||||||
|
|
@ -264,6 +316,32 @@ afterEach(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("runNonInteractivePrompt", () => {
|
describe("runNonInteractivePrompt", () => {
|
||||||
|
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
|
||||||
|
const output = await capture({ format: "json", turn: successfulGrep })
|
||||||
|
const events = output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "tool_use",
|
||||||
|
part: {
|
||||||
|
tool: "grep",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
output: expect.stringContaining("Found 2 matches"),
|
||||||
|
metadata: {
|
||||||
|
structured: { matches: 2 },
|
||||||
|
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
|
||||||
|
expect(events[0].part.state.metadata.result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("uses session.wait then reconciles projected output without a terminal event", async () => {
|
test("uses session.wait then reconciles projected output without a terminal event", async () => {
|
||||||
const idle = Promise.withResolvers<void>()
|
const idle = Promise.withResolvers<void>()
|
||||||
let done = false
|
let done = false
|
||||||
|
|
@ -319,6 +397,26 @@ describe("runNonInteractivePrompt", () => {
|
||||||
error: { type: "provider.transport", message: "instructions unavailable" },
|
error: { type: "provider.transport", message: "instructions unavailable" },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
expect(output.exitCode).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("waits for a terminal failure when idle wins before projection", async () => {
|
||||||
|
for (const promotedBeforeFailure of [true, false]) {
|
||||||
|
const output = await capture({
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [
|
||||||
|
...(promotedBeforeFailure ? [prompted(messageID)] : []),
|
||||||
|
executionFailed("selection unavailable"),
|
||||||
|
],
|
||||||
|
messages: (messageID) =>
|
||||||
|
promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [],
|
||||||
|
wait: () => Promise.resolve(),
|
||||||
|
terminalDelay: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output.exitCode).toBe(1)
|
||||||
|
expect(output.stdout).toContain("selection unavailable")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
||||||
|
|
@ -413,14 +511,14 @@ describe("runNonInteractivePrompt", () => {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(output).toEqual({ stdout: "", stderr: "" })
|
expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders native failed tool output before the terminal error", async () => {
|
test("renders a native terminal failure snapshot when live progress was missed", async () => {
|
||||||
const rendered: SessionMessageAssistantTool[] = []
|
const rendered: SessionMessageAssistantTool[] = []
|
||||||
const failed: SessionMessageAssistantTool[] = []
|
const failed: SessionMessageAssistantTool[] = []
|
||||||
await capture({
|
await capture({
|
||||||
turn: failedTool,
|
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
|
||||||
renderTool: (part) => {
|
renderTool: (part) => {
|
||||||
rendered.push(part)
|
rendered.push(part)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../src/version"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, FileSystem, Scope } from "effect"
|
import { Effect, FileSystem, Scope } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
|
|
@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||||
fetch() {
|
fetch() {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
healthy: true,
|
healthy: true,
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||||
registration,
|
registration,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id,
|
id,
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
url: server.url.toString(),
|
url: server.url.toString(),
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { OPENCODE_VERSION } from "../src/version"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
|
|
@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
|
||||||
)
|
)
|
||||||
const stale = {
|
const stale = {
|
||||||
id: "stale",
|
id: "stale",
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
url: "http://127.0.0.1:1",
|
url: "http://127.0.0.1:1",
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
password: "stale",
|
password: "stale",
|
||||||
|
|
@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||||
fetch() {
|
fetch() {
|
||||||
requests.count += 1
|
requests.count += 1
|
||||||
if (requests.count === 2) recognizing.resolve()
|
if (requests.count === 2) recognizing.resolve()
|
||||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
|
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
|
@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||||
registration,
|
registration,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: "stale",
|
id: "stale",
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
url: "http://127.0.0.1:1",
|
url: "http://127.0.0.1:1",
|
||||||
pid: 2_147_483_647,
|
pid: 2_147_483_647,
|
||||||
password: "stale",
|
password: "stale",
|
||||||
|
|
@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||||
await Bun.sleep(8_000)
|
await Bun.sleep(8_000)
|
||||||
const info = {
|
const info = {
|
||||||
id: "incumbent",
|
id: "incumbent",
|
||||||
version: InstallationVersion,
|
version: OPENCODE_VERSION,
|
||||||
url: `http://127.0.0.1:${listener.port}`,
|
url: `http://127.0.0.1:${listener.port}`,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
password: "incumbent",
|
password: "incumbent",
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,15 @@ describe("session target resolver", () => {
|
||||||
expect(order).toEqual(["prepare", "create"])
|
expect(order).toEqual(["prepare", "create"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses the agent resolved by the server for a fresh Session", async () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
|
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||||
|
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
|
||||||
|
|
||||||
|
const target = await resolveSessionTarget({ client, prepare })
|
||||||
|
expect(target.agent).toBe("review")
|
||||||
|
})
|
||||||
|
|
||||||
test("does not retry an ambiguous Session creation", async () => {
|
test("does not retry an ambiguous Session creation", async () => {
|
||||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ export type SessionMessageProviderState7 = { [x: string]: any }
|
||||||
|
|
||||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||||
|
|
||||||
|
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||||
|
|
||||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||||
|
|
||||||
export type ModelVariant = {
|
export type ModelVariant = {
|
||||||
|
|
@ -1094,7 +1096,7 @@ export type TuiCommandExecute = {
|
||||||
| "prompt.clear"
|
| "prompt.clear"
|
||||||
| "prompt.submit"
|
| "prompt.submit"
|
||||||
| "agent.cycle"
|
| "agent.cycle"
|
||||||
| string
|
| (string & {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1385,23 +1387,7 @@ export type SessionToolCalled = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionToolFailed = {
|
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.tool.failed"
|
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
|
||||||
location?: LocationRef
|
|
||||||
data: {
|
|
||||||
sessionID: string
|
|
||||||
assistantMessageID: string
|
|
||||||
callID: string
|
|
||||||
error: SessionStructuredError
|
|
||||||
result?: any
|
|
||||||
executed: boolean
|
|
||||||
resultState?: SessionMessageProviderState7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelCost = {
|
export type ModelCost = {
|
||||||
tier?: { type: "context"; size: number }
|
tier?: { type: "context"; size: number }
|
||||||
|
|
@ -1847,22 +1833,6 @@ export type SessionMessageToolStateError = {
|
||||||
result?: JsonValue
|
result?: JsonValue
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionToolProgress = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.tool.progress"
|
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
|
||||||
location?: LocationRef
|
|
||||||
data: {
|
|
||||||
sessionID: string
|
|
||||||
assistantMessageID: string
|
|
||||||
callID: string
|
|
||||||
structured: { [x: string]: any }
|
|
||||||
content: Array<LLMToolContent>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionToolSuccess = {
|
export type SessionToolSuccess = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
|
|
@ -1882,6 +1852,41 @@ export type SessionToolSuccess = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionToolFailed = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.tool.failed"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
callID: string
|
||||||
|
error: SessionStructuredError
|
||||||
|
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
result?: any
|
||||||
|
executed: boolean
|
||||||
|
resultState?: SessionMessageProviderState7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionToolProgress = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.tool.progress"
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
callID: string
|
||||||
|
structured: { [x: string]: any }
|
||||||
|
content: Array<LLMToolContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionMessageCompaction =
|
export type SessionMessageCompaction =
|
||||||
| SessionMessageCompactionRunning
|
| SessionMessageCompactionRunning
|
||||||
| SessionMessageCompactionCompleted
|
| SessionMessageCompactionCompleted
|
||||||
|
|
@ -1893,6 +1898,7 @@ export type ModelInfo = {
|
||||||
providerID: string
|
providerID: string
|
||||||
family?: string
|
family?: string
|
||||||
name: string
|
name: string
|
||||||
|
compatibility?: ModelCompatibility
|
||||||
package?: string
|
package?: string
|
||||||
settings?: { [x: string]: JsonValue }
|
settings?: { [x: string]: JsonValue }
|
||||||
headers?: { [x: string]: string }
|
headers?: { [x: string]: string }
|
||||||
|
|
@ -2219,7 +2225,6 @@ export type SessionEventDurable =
|
||||||
| SessionToolInputStarted
|
| SessionToolInputStarted
|
||||||
| SessionToolInputEnded
|
| SessionToolInputEnded
|
||||||
| SessionToolCalled
|
| SessionToolCalled
|
||||||
| SessionToolProgress
|
|
||||||
| SessionToolSuccess
|
| SessionToolSuccess
|
||||||
| SessionToolFailed
|
| SessionToolFailed
|
||||||
| SessionRetryScheduled
|
| SessionRetryScheduled
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ ultimate source of truth.
|
||||||
## Values and literals
|
## Values and literals
|
||||||
|
|
||||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams.
|
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||||
|
iterators, and synchronous generators.
|
||||||
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
|
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
|
||||||
`undefined` are no-ops, while arrays are rejected.
|
`undefined` are no-ops, while arrays are rejected.
|
||||||
- [x] Template literals with interpolation.
|
- [x] Template literals with interpolation.
|
||||||
|
|
@ -57,7 +58,9 @@ ultimate source of truth.
|
||||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||||
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||||
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||||
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
|
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||||
|
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
|
||||||
|
or binding/default failure.
|
||||||
|
|
||||||
## Statements and control flow
|
## Statements and control flow
|
||||||
|
|
||||||
|
|
@ -65,7 +68,8 @@ ultimate source of truth.
|
||||||
- [x] `if`/`else` and conditional expressions.
|
- [x] `if`/`else` and conditional expressions.
|
||||||
- [x] `switch`, including default clauses and fallthrough.
|
- [x] `switch`, including default clauses and fallthrough.
|
||||||
- [x] `for`, `while`, and `do...while`.
|
- [x] `for`, `while`, and `do...while`.
|
||||||
- [x] `for...of` over arrays, strings, Maps, Sets, and URLSearchParams.
|
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||||
|
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||||
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
|
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
|
||||||
- [x] Unlabeled `break` and `continue`.
|
- [x] Unlabeled `break` and `continue`.
|
||||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||||
|
|
@ -75,7 +79,7 @@ ultimate source of truth.
|
||||||
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
|
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
|
||||||
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
|
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
|
||||||
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
|
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
|
||||||
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
|
JavaScript; only their `next()` results are awaited. Confined sync and async generators are iterable here.
|
||||||
|
|
||||||
## Functions and callbacks
|
## Functions and callbacks
|
||||||
|
|
||||||
|
|
@ -104,7 +108,27 @@ ultimate source of truth.
|
||||||
- [ ] User-defined constructor calls.
|
- [ ] User-defined constructor calls.
|
||||||
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
|
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
|
||||||
- [ ] Classes and private fields.
|
- [ ] Classes and private fields.
|
||||||
- [ ] Generator functions and `yield`.
|
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
|
||||||
|
`next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering,
|
||||||
|
`try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync
|
||||||
|
iterator but preserves values supplied by a manually implemented async iterator. Generator values are opaque
|
||||||
|
runtime references.
|
||||||
|
- [x] Synchronous generators and custom synchronous iterators are consumed stepwise by array/argument spread, array
|
||||||
|
destructuring, `Array.from`, Map/Set/URLSearchParams construction, `Object.fromEntries`, Object/Map `groupBy`,
|
||||||
|
Promise combinators, `AggregateError`, and `Math.sumPrecise`. Mapper/grouping callbacks interleave with iterator
|
||||||
|
steps; synchronous consumers preserve yielded promise objects rather than awaiting them. Async generators are
|
||||||
|
rejected by every synchronous consumer.
|
||||||
|
- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and
|
||||||
|
intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error
|
||||||
|
wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`.
|
||||||
|
- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield,
|
||||||
|
and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected
|
||||||
|
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
|
||||||
|
and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite
|
||||||
|
deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions,
|
||||||
|
proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests
|
||||||
|
asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These
|
||||||
|
are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged.
|
||||||
|
|
||||||
## Expressions and operators
|
## Expressions and operators
|
||||||
|
|
||||||
|
|
@ -112,8 +136,8 @@ ultimate source of truth.
|
||||||
- [x] Optional property access and optional calls.
|
- [x] Optional property access and optional calls.
|
||||||
- [x] Function/tool calls and spread arguments.
|
- [x] Function/tool calls and spread arguments.
|
||||||
- [x] Sequence expressions (the comma operator).
|
- [x] Sequence expressions (the comma operator).
|
||||||
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
|
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||||
continuation one reaction turn.
|
`await` still defers its continuation one reaction turn.
|
||||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
||||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||||
|
|
@ -128,10 +152,11 @@ ultimate source of truth.
|
||||||
## Promises and tools
|
## Promises and tools
|
||||||
|
|
||||||
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
|
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
|
||||||
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
|
- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from
|
||||||
|
a function/program.
|
||||||
- [x] `Promise.resolve` and `Promise.reject`.
|
- [x] `Promise.resolve` and `Promise.reject`.
|
||||||
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
|
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over finite collections, custom synchronous
|
||||||
promises and plain values.
|
iterators, and synchronous generators containing promises and plain values.
|
||||||
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
|
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
|
||||||
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
|
||||||
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
|
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
|
||||||
|
|
@ -154,11 +179,14 @@ ultimate source of truth.
|
||||||
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
|
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
|
||||||
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
|
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
|
||||||
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
|
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
|
||||||
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
|
throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and
|
||||||
promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including
|
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
|
||||||
`.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data
|
accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot
|
||||||
boundary.
|
cross the data boundary.
|
||||||
- [ ] Thenable assimilation; objects with a callable `then` field remain plain data.
|
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
|
||||||
|
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
|
||||||
|
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
||||||
|
and a JavaScript `this` receiver remain outside the supported object/function model.
|
||||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
||||||
last definition supplied for a canonical path wins.
|
last definition supplied for a canonical path wins.
|
||||||
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||||
|
|
@ -175,14 +203,16 @@ ultimate source of truth.
|
||||||
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
|
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
|
||||||
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
||||||
- [x] Computed property names and object spread.
|
- [x] Computed property names and object spread.
|
||||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
|
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
|
||||||
|
synchronous iterator support for `fromEntries`.
|
||||||
- [x] `Object.keys` over arrays and tool references.
|
- [x] `Object.keys` over arrays and tool references.
|
||||||
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
||||||
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
|
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
|
||||||
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
|
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
|
||||||
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
|
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
|
||||||
- [x] `Object.is` for supported data values.
|
- [x] `Object.is` for supported data values.
|
||||||
- [x] `Object.groupBy` over supported collection iterables, with string-key coercion and null-prototype results.
|
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||||
|
and null-prototype results.
|
||||||
|
|
||||||
## Arrays
|
## Arrays
|
||||||
|
|
||||||
|
|
@ -190,7 +220,7 @@ ultimate source of truth.
|
||||||
array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like
|
array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like
|
||||||
JavaScript, and host results normalize holes to `null`.
|
JavaScript, and host results normalize holes to `null`.
|
||||||
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
|
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
|
||||||
`(value, index)` arguments.
|
`(value, index)` arguments and stepwise synchronous iterator consumption.
|
||||||
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
|
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
|
||||||
- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and
|
- [x] Searching/tests: `find`, `findIndex`, `findLast`, `findLastIndex`, `some`, `every`, `includes`, `indexOf`, and
|
||||||
`lastIndexOf`.
|
`lastIndexOf`.
|
||||||
|
|
@ -245,7 +275,8 @@ ultimate source of truth.
|
||||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||||
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
||||||
and unknown `Promise` statics keep their descriptive error.
|
and unknown `Promise` statics keep their descriptive error.
|
||||||
- [x] `Math.sumPrecise` over supported collection iterables, rejecting non-number elements without coercion.
|
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
|
||||||
|
elements without coercion.
|
||||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||||
|
|
||||||
## JSON and console
|
## JSON and console
|
||||||
|
|
@ -297,10 +328,10 @@ ultimate source of truth.
|
||||||
|
|
||||||
## Map and Set
|
## Map and Set
|
||||||
|
|
||||||
- [x] Static `Map.groupBy` over supported collection iterables, preserving key identity.
|
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
|
||||||
- [x] `new Map()` from entry arrays or another Map.
|
- [x] `new Map()` from synchronous iterables of entries.
|
||||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||||
- [x] `new Set()` from arrays, strings, or another Set.
|
- [x] `new Set()` from synchronous iterables.
|
||||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||||
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
||||||
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
||||||
|
|
@ -316,7 +347,7 @@ ultimate source of truth.
|
||||||
- [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`,
|
- [x] Readable URL fields: `href`, `origin`, `protocol`, `username`, `password`, `host`, `hostname`, `port`,
|
||||||
`pathname`, `search`, and `hash`.
|
`pathname`, `search`, and `hash`.
|
||||||
- [x] Writable URL fields except `origin`.
|
- [x] Writable URL fields except `origin`.
|
||||||
- [x] `new URLSearchParams()` from query strings, data objects, pairs, Maps, and URLSearchParams.
|
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
|
||||||
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
||||||
`entries`, `toString`, and `size`.
|
`entries`, `toString`, and `size`.
|
||||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||||
|
|
@ -326,7 +357,7 @@ ultimate source of truth.
|
||||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||||
or without `new`.
|
or without `new`.
|
||||||
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
||||||
an all-rejected `Promise.any`.
|
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
|
||||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
|
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
|
||||||
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
|
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
|
||||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
import type { Diagnostic } from "../codemode.js"
|
import type { Diagnostic } from "../codemode.js"
|
||||||
import { ToolError } from "../tool-error.js"
|
import { ToolError } from "../tool-error.js"
|
||||||
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||||
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
||||||
import { containsRuntimeReference } from "./references.js"
|
import { containsRuntimeReference } from "./references.js"
|
||||||
import { spreadItems } from "../stdlib/collections.js"
|
import { type SyncIteratorRunner } from "./iterator.js"
|
||||||
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
|
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
|
||||||
|
|
||||||
export const normalizeError = (error: unknown): Diagnostic => {
|
export const normalizeError = (error: unknown): Diagnostic => {
|
||||||
|
|
@ -79,15 +80,27 @@ export const caughtErrorValue = (thrown: unknown): unknown => {
|
||||||
return createErrorValue(name, normalizeError(thrown).message)
|
return createErrorValue(name, normalizeError(thrown).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
|
export const constructErrorValue = (name: string, args: Array<unknown>): SafeObject =>
|
||||||
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
|
createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||||
const errors = spreadItems(args[0])
|
|
||||||
if (errors === undefined) {
|
export const constructAggregateErrorValue = <R>(
|
||||||
throw new InterpreterRuntimeError(
|
runner: SyncIteratorRunner<R>,
|
||||||
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
|
args: Array<unknown>,
|
||||||
node,
|
node: AstNode,
|
||||||
).as("TypeError")
|
): Effect.Effect<SafeObject, unknown, R> =>
|
||||||
}
|
Effect.gen(function* () {
|
||||||
// Error values must not alias caller-owned arrays.
|
const cursor = yield* runner.syncIterator(args[0], node)
|
||||||
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
|
if (cursor === undefined) {
|
||||||
}
|
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node).as(
|
||||||
|
"TypeError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const errors: Array<unknown> = []
|
||||||
|
while (true) {
|
||||||
|
const step = yield* cursor.next
|
||||||
|
if (step.done) {
|
||||||
|
return createAggregateErrorValue(errors, args[1] === undefined ? "" : coerceToString(args[1]))
|
||||||
|
}
|
||||||
|
errors.push(step.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
21
packages/codemode/src/interpreter/iterator.ts
Normal file
21
packages/codemode/src/interpreter/iterator.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Effect, Exit } from "effect"
|
||||||
|
import type { AstNode } from "./model.js"
|
||||||
|
|
||||||
|
export type IteratorCursor<R> = {
|
||||||
|
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R>
|
||||||
|
readonly close: Effect.Effect<void, unknown, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncIteratorRunner<R> = {
|
||||||
|
readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preserveConsumerError = <A, R>(
|
||||||
|
cursor: IteratorCursor<R>,
|
||||||
|
effect: Effect.Effect<A, unknown, R>,
|
||||||
|
): Effect.Effect<A, unknown, R> =>
|
||||||
|
Effect.flatMap(Effect.exit(effect), (exit) =>
|
||||||
|
Exit.isSuccess(exit)
|
||||||
|
? Effect.succeed(exit.value)
|
||||||
|
: Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)),
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ import { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type AstNode,
|
type AstNode,
|
||||||
CodeModeFunction,
|
CodeModeFunction,
|
||||||
|
CodeModeGenerator,
|
||||||
CoercionFunction,
|
CoercionFunction,
|
||||||
ErrorConstructorReference,
|
ErrorConstructorReference,
|
||||||
GlobalMethodReference,
|
GlobalMethodReference,
|
||||||
|
|
@ -33,6 +34,7 @@ import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } fro
|
||||||
import { invokeStringStatic } from "../stdlib/string.js"
|
import { invokeStringStatic } from "../stdlib/string.js"
|
||||||
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
||||||
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
|
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
|
||||||
|
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
|
||||||
|
|
||||||
export type CallbackRunner<R> = {
|
export type CallbackRunner<R> = {
|
||||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
|
|
@ -360,19 +362,12 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||||
return Array.isArray(args[0])
|
return Array.isArray(args[0])
|
||||||
case "of":
|
case "of":
|
||||||
return [...args]
|
return [...args]
|
||||||
case "from":
|
|
||||||
return arrayFromItems(args[0], node)
|
|
||||||
default:
|
default:
|
||||||
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
|
throw new InterpreterRuntimeError(`Array.${name} is not available.`, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
|
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
|
||||||
if (source instanceof CodeModeMap) return Array.from(source.map.entries(), ([key, item]) => [key, item])
|
|
||||||
if (source instanceof CodeModeSet) return Array.from(source.set.values())
|
|
||||||
if (source instanceof CodeModeURLSearchParams) {
|
|
||||||
return Array.from(source.params.entries(), ([key, value]) => [key, value])
|
|
||||||
}
|
|
||||||
if (source instanceof CodeModePromise) {
|
if (source instanceof CodeModePromise) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||||
|
|
@ -380,15 +375,16 @@ const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
|
||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (typeof source === "string") return Array.from(source)
|
|
||||||
if (Array.isArray(source)) return [...source]
|
|
||||||
if (
|
if (
|
||||||
source !== null &&
|
source !== null &&
|
||||||
typeof source === "object" &&
|
typeof source === "object" &&
|
||||||
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
|
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
|
||||||
typeof (source as { length?: unknown }).length === "number"
|
typeof (source as { length?: unknown }).length === "number"
|
||||||
) {
|
) {
|
||||||
return Array.from(source as ArrayLike<unknown>)
|
const length = (source as { length: number }).length
|
||||||
|
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
|
||||||
|
if (normalized > 4_294_967_295) throw new RangeError("Invalid array length")
|
||||||
|
return { length: normalized, source }
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
"Array.from expects an array, string, Map, Set, or array-like value.",
|
"Array.from expects an array, string, Map, Set, or array-like value.",
|
||||||
|
|
@ -398,24 +394,42 @@ const arrayFromItems = (source: unknown, node: AstNode): Array<unknown> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeArrayFrom = <R>(
|
export const invokeArrayFrom = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
const items = arrayFromItems(args[0], node)
|
const source = args[0]
|
||||||
if (args.length < 2 || args[1] === undefined) return Effect.succeed(items)
|
const apply =
|
||||||
const apply = applyCollectionCallback(runner, args[1], "Array.from", node)
|
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const values: Array<unknown> = []
|
const cursor = yield* runner.syncIterator(source, node)
|
||||||
for (let index = 0; index < items.length; index += 1) {
|
if (cursor === undefined) {
|
||||||
values.push(yield* apply([items[index], index]))
|
if (source instanceof CodeModeGenerator) {
|
||||||
|
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node).as(
|
||||||
|
"TypeError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const arrayLike = arrayLikeSource(source, node)
|
||||||
|
const values: Array<unknown> = []
|
||||||
|
for (let index = 0; index < arrayLike.length; index += 1) {
|
||||||
|
const item = Reflect.get(arrayLike.source, index)
|
||||||
|
values.push(apply === undefined ? item : yield* apply([item, index]))
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
const values: Array<unknown> = []
|
||||||
|
let index = 0
|
||||||
|
while (true) {
|
||||||
|
const step = yield* cursor.next
|
||||||
|
if (step.done) return values
|
||||||
|
values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index])))
|
||||||
|
index += 1
|
||||||
}
|
}
|
||||||
return values
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeGroupBy = <R>(
|
export const invokeGroupBy = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
|
||||||
namespace: "Map" | "Object",
|
namespace: "Map" | "Object",
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
|
|
@ -425,47 +439,50 @@ export const invokeGroupBy = <R>(
|
||||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||||
}
|
}
|
||||||
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
|
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
|
||||||
const items = supportedIterableItems(source)
|
|
||||||
if (items === undefined) {
|
|
||||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
|
||||||
}
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
const cursor = yield* runner.syncIterator(source, node)
|
||||||
|
if (cursor === undefined) {
|
||||||
|
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
|
||||||
|
}
|
||||||
if (namespace === "Map") {
|
if (namespace === "Map") {
|
||||||
const result = new CodeModeMap()
|
const result = new CodeModeMap()
|
||||||
let index = 0
|
let index = 0
|
||||||
for (const item of items) {
|
while (true) {
|
||||||
const key = yield* apply([item, index])
|
const step = yield* cursor.next
|
||||||
|
if (step.done) return result
|
||||||
|
const item = step.value
|
||||||
|
const key = yield* preserveConsumerError(cursor, apply([item, index]))
|
||||||
const group = result.map.get(key)
|
const group = result.map.get(key)
|
||||||
if (group === undefined) result.map.set(key, [item])
|
if (group === undefined) result.map.set(key, [item])
|
||||||
else (group as Array<unknown>).push(item)
|
else (group as Array<unknown>).push(item)
|
||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: SafeObject = Object.create(null) as SafeObject
|
const result: SafeObject = Object.create(null) as SafeObject
|
||||||
let index = 0
|
let index = 0
|
||||||
for (const item of items) {
|
while (true) {
|
||||||
const key = yield* coerceGroupByPropertyKey(runner, yield* apply([item, index]), node)
|
const step = yield* cursor.next
|
||||||
|
if (step.done) return result
|
||||||
|
const item = step.value
|
||||||
|
const key = yield* preserveConsumerError(
|
||||||
|
cursor,
|
||||||
|
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
|
||||||
|
)
|
||||||
if (isBlockedMember(key)) {
|
if (isBlockedMember(key)) {
|
||||||
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
return yield* preserveConsumerError(
|
||||||
|
cursor,
|
||||||
|
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const group = result[key]
|
const group = result[key]
|
||||||
if (group === undefined) result[key] = [item]
|
if (group === undefined) result[key] = [item]
|
||||||
else (group as Array<unknown>).push(item)
|
else (group as Array<unknown>).push(item)
|
||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedIterableItems = (source: unknown): Iterable<unknown> | undefined => {
|
|
||||||
if (Array.isArray(source) || typeof source === "string") return source
|
|
||||||
if (source instanceof CodeModeMap) return source.map.entries()
|
|
||||||
if (source instanceof CodeModeSet) return source.set.values()
|
|
||||||
if (source instanceof CodeModeURLSearchParams) return source.params.entries()
|
|
||||||
}
|
|
||||||
|
|
||||||
const coerceGroupByPropertyKey = <R>(
|
const coerceGroupByPropertyKey = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R>,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Effect } from "effect"
|
||||||
import type { SafeObject } from "../tool-runtime.js"
|
import type { SafeObject } from "../tool-runtime.js"
|
||||||
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
|
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
|
||||||
|
|
||||||
|
|
@ -45,6 +46,27 @@ export class CodeModeFunction {
|
||||||
readonly body: AstNode,
|
readonly body: AstNode,
|
||||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||||
readonly async: boolean,
|
readonly async: boolean,
|
||||||
|
readonly generator: boolean,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeneratorRequestKind = "next" | "return" | "throw"
|
||||||
|
|
||||||
|
export class CodeModeGenerator {
|
||||||
|
constructor(
|
||||||
|
readonly asynchronous: boolean,
|
||||||
|
readonly request: (
|
||||||
|
kind: GeneratorRequestKind,
|
||||||
|
value: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
) => Effect.Effect<unknown, unknown, unknown>,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeneratorMethodReference {
|
||||||
|
constructor(
|
||||||
|
readonly generator: CodeModeGenerator,
|
||||||
|
readonly kind: GeneratorRequestKind | "iterator",
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +150,10 @@ export class ProgramThrow {
|
||||||
constructor(readonly value: unknown) {}
|
constructor(readonly value: unknown) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class GeneratorReturn {
|
||||||
|
constructor(readonly value: unknown) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class ErrorConstructorReference {
|
export class ErrorConstructorReference {
|
||||||
constructor(readonly name: string) {}
|
constructor(readonly name: string) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import {
|
||||||
import { caughtErrorValue, normalizeError } from "./errors.js"
|
import { caughtErrorValue, normalizeError } from "./errors.js"
|
||||||
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
|
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
|
||||||
import { typeofValue } from "./references.js"
|
import { typeofValue } from "./references.js"
|
||||||
import { spreadItems } from "../stdlib/collections.js"
|
|
||||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||||
import { CodeModePromise } from "../values.js"
|
import { CodeModePromise } from "../values.js"
|
||||||
|
import type { SyncIteratorRunner } from "./iterator.js"
|
||||||
|
|
||||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||||
export class PromiseRuntime<R> {
|
export class PromiseRuntime<R> {
|
||||||
|
|
@ -64,6 +64,10 @@ export class PromiseRuntime<R> {
|
||||||
return Fiber.await(promise.fiber)
|
return Fiber.await(promise.fiber)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fork(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<void, never, R> {
|
||||||
|
return Effect.asVoid(Effect.forkIn(effect, this.scope, { startImmediately: true }))
|
||||||
|
}
|
||||||
|
|
||||||
diagnostics(): Array<Diagnostic> {
|
diagnostics(): Array<Diagnostic> {
|
||||||
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||||
}
|
}
|
||||||
|
|
@ -83,114 +87,138 @@ export class PromiseRuntime<R> {
|
||||||
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
||||||
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
||||||
|
|
||||||
export const invokePromiseMethod = <R>(
|
export const resolvePromiseValue = <R>(
|
||||||
runner: CallbackRunner<R>,
|
runner: CallbackRunner<R>,
|
||||||
|
value: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
own?: { promise?: CodeModePromise },
|
||||||
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
|
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
|
||||||
|
if (value instanceof CodeModePromise) return runner.settlePromise(value)
|
||||||
|
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
|
||||||
|
const then = (value as SafeObject).then
|
||||||
|
if (typeofValue(then) !== "function") return Effect.succeed(value)
|
||||||
|
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
// Promise resolution invokes a thenable's method in a later job.
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||||
|
const resolve = new PromiseCapabilityFunction((result) => {
|
||||||
|
Deferred.doneUnsafe(deferred, Exit.succeed(result))
|
||||||
|
})
|
||||||
|
const reject = new PromiseCapabilityFunction((reason) => {
|
||||||
|
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason)))
|
||||||
|
})
|
||||||
|
const executed = yield* Effect.exit(runner.invokeCallable(then, [resolve, reject], node))
|
||||||
|
if (!Exit.isSuccess(executed)) {
|
||||||
|
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||||
|
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||||
|
}
|
||||||
|
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolvePromise = <R>(
|
||||||
|
runner: CallbackRunner<R>,
|
||||||
|
promises: PromiseRuntime<R>,
|
||||||
|
value: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<CodeModePromise, never, R> => {
|
||||||
|
if (value instanceof CodeModePromise) return Effect.succeed(value)
|
||||||
|
const box: { promise?: CodeModePromise } = {}
|
||||||
|
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||||
|
box.promise = promise
|
||||||
|
return promise
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invokePromiseMethod = <R>(
|
||||||
|
runner: CallbackRunner<R> & SyncIteratorRunner<R>,
|
||||||
promises: PromiseRuntime<R>,
|
promises: PromiseRuntime<R>,
|
||||||
ref: PromiseMethodReference,
|
ref: PromiseMethodReference,
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
): Effect.Effect<unknown, unknown, R> => {
|
): Effect.Effect<unknown, unknown, R> => {
|
||||||
if (ref.name === "resolve") {
|
if (ref.name === "resolve") {
|
||||||
const value = args[0]
|
return resolvePromise(runner, promises, args[0], node)
|
||||||
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
|
||||||
}
|
}
|
||||||
if (ref.name === "reject") {
|
if (ref.name === "reject") {
|
||||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||||
}
|
}
|
||||||
|
|
||||||
const spread = spreadItems(args[0])
|
return promises.create(
|
||||||
if (spread === undefined) {
|
Effect.gen(function* () {
|
||||||
return promises.create(
|
const cursor = yield* runner.syncIterator(args[0], node)
|
||||||
Effect.fail(
|
if (cursor === undefined) {
|
||||||
new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(
|
||||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
`Promise.${ref.name} expects an array or other synchronous iterable.`,
|
||||||
node,
|
node,
|
||||||
).as("TypeError"),
|
).as("TypeError")
|
||||||
),
|
}
|
||||||
)
|
const items: Array<CodeModePromise> = []
|
||||||
}
|
while (true) {
|
||||||
const items = Array.from(spread)
|
const step = yield* cursor.next
|
||||||
|
if (step.done) break
|
||||||
|
const item = yield* resolvePromise(runner, promises, step.value, node)
|
||||||
|
promises.markObserved(item)
|
||||||
|
items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
if (ref.name === "all") {
|
||||||
if (item instanceof CodeModePromise) promises.markObserved(item)
|
return yield* settleAfterTurn(
|
||||||
}
|
Effect.all(
|
||||||
|
items.map((item) => Effect.flatten(promises.await(item))),
|
||||||
switch (ref.name) {
|
{ concurrency: "unbounded" },
|
||||||
case "all": {
|
|
||||||
const observations = items.map((item) =>
|
|
||||||
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
|
||||||
)
|
|
||||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
|
||||||
}
|
|
||||||
case "allSettled": {
|
|
||||||
const observations = items.map((item) =>
|
|
||||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
|
||||||
)
|
|
||||||
return promises.create(
|
|
||||||
settleAfterTurn(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const outcomes: Array<unknown> = []
|
|
||||||
for (const observation of observations) {
|
|
||||||
const exit = yield* observation
|
|
||||||
if (Exit.isSuccess(exit)) {
|
|
||||||
outcomes.push(
|
|
||||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
||||||
// Teardown interruption is not a program-level rejection.
|
|
||||||
return yield* Effect.failCause(exit.cause)
|
|
||||||
}
|
|
||||||
outcomes.push(
|
|
||||||
Object.assign(Object.create(null) as SafeObject, {
|
|
||||||
status: "rejected",
|
|
||||||
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return outcomes
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "race": {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return promises.create(
|
|
||||||
Effect.fail(
|
|
||||||
new InterpreterRuntimeError(
|
|
||||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
|
||||||
node,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const observations = items.map((item) =>
|
if (ref.name === "allSettled") {
|
||||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
const outcomes: Array<unknown> = []
|
||||||
)
|
for (const item of items) {
|
||||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
const exit = yield* promises.await(item)
|
||||||
}
|
if (Exit.isSuccess(exit)) {
|
||||||
case "any": {
|
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||||
|
outcomes.push(
|
||||||
|
Object.assign(Object.create(null) as SafeObject, {
|
||||||
|
status: "rejected",
|
||||||
|
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
|
if (ref.name === "race") {
|
||||||
|
if (items.length === 0) {
|
||||||
|
throw new InterpreterRuntimeError(
|
||||||
|
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
|
||||||
|
}
|
||||||
const flipped = items.map((item) =>
|
const flipped = items.map((item) =>
|
||||||
item instanceof CodeModePromise
|
Effect.flatMap(promises.await(item), (exit) => {
|
||||||
? Effect.flatMap(promises.await(item), (exit) => {
|
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
||||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
||||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
|
||||||
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
|
}),
|
||||||
})
|
|
||||||
: Effect.fail(new PromiseAnyFulfilled(item)),
|
|
||||||
)
|
)
|
||||||
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
|
return yield* settleAfterTurn(
|
||||||
Effect.flatMap((reasons) =>
|
Effect.all(flipped, { concurrency: "unbounded" }).pipe(
|
||||||
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
|
Effect.flatMap((reasons) =>
|
||||||
),
|
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
|
||||||
Effect.catch((error) =>
|
),
|
||||||
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
|
Effect.catch((error) =>
|
||||||
|
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return promises.create(settleAfterTurn(body))
|
}),
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokePromiseInstanceMethod = <R>(
|
export const invokePromiseInstanceMethod = <R>(
|
||||||
|
|
@ -224,15 +252,11 @@ export const constructPromise = <R>(
|
||||||
}
|
}
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||||
const box: { own?: CodeModePromise } = {}
|
const box: { promise?: CodeModePromise } = {}
|
||||||
const promise = yield* promises.create(
|
const promise = yield* promises.create(
|
||||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||||
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
|
|
||||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
|
||||||
return runner.settlePromise(value)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
box.own = promise
|
box.promise = promise
|
||||||
const resolve = new PromiseCapabilityFunction((value) => {
|
const resolve = new PromiseCapabilityFunction((value) => {
|
||||||
Deferred.doneUnsafe(deferred, Exit.succeed(value))
|
Deferred.doneUnsafe(deferred, Exit.succeed(value))
|
||||||
})
|
})
|
||||||
|
|
@ -288,19 +312,17 @@ const chainReaction = <R>(
|
||||||
method: string,
|
method: string,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
): Effect.Effect<CodeModePromise, never, R> => {
|
): Effect.Effect<CodeModePromise, never, R> => {
|
||||||
const box: { derived?: CodeModePromise } = {}
|
const box: { promise?: CodeModePromise } = {}
|
||||||
const body = Effect.gen(function* () {
|
const body = Effect.gen(function* () {
|
||||||
const exit = yield* reactionExit(promises, source)
|
const exit = yield* reactionExit(promises, source)
|
||||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||||
if (handler === undefined) return yield* exit
|
if (handler === undefined) return yield* exit
|
||||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
return yield* resolvePromiseValue(runner, result, node, box)
|
||||||
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
return Effect.map(promises.create(body), (derived) => {
|
return Effect.map(promises.create(body), (derived) => {
|
||||||
box.derived = derived
|
box.promise = derived
|
||||||
return derived
|
return derived
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +340,13 @@ const chainFinally = <R>(
|
||||||
const exit = yield* reactionExit(promises, source)
|
const exit = yield* reactionExit(promises, source)
|
||||||
if (cleanup !== undefined) {
|
if (cleanup !== undefined) {
|
||||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
||||||
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
|
const intermediate = yield* promises.create(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
|
||||||
|
return yield* exit
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return yield* runner.settlePromise(intermediate)
|
||||||
}
|
}
|
||||||
return yield* exit
|
return yield* exit
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import {
|
import {
|
||||||
type AstNode,
|
type AstNode,
|
||||||
CodeModeFunction,
|
CodeModeFunction,
|
||||||
|
CodeModeGenerator,
|
||||||
CoercionFunction,
|
CoercionFunction,
|
||||||
ErrorConstructorReference,
|
ErrorConstructorReference,
|
||||||
GlobalMethodReference,
|
GlobalMethodReference,
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
|
GeneratorMethodReference,
|
||||||
InterpreterRuntimeError,
|
InterpreterRuntimeError,
|
||||||
IntrinsicReference,
|
IntrinsicReference,
|
||||||
JsonMethodReference,
|
JsonMethodReference,
|
||||||
|
|
@ -21,6 +23,8 @@ import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||||
|
|
||||||
export const isRuntimeReference = (value: unknown): boolean =>
|
export const isRuntimeReference = (value: unknown): boolean =>
|
||||||
value instanceof CodeModeFunction ||
|
value instanceof CodeModeFunction ||
|
||||||
|
value instanceof CodeModeGenerator ||
|
||||||
|
value instanceof GeneratorMethodReference ||
|
||||||
value instanceof ToolReference ||
|
value instanceof ToolReference ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalNamespace ||
|
value instanceof GlobalNamespace ||
|
||||||
|
|
@ -107,6 +111,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
|
||||||
export const typeofValue = (value: unknown): string => {
|
export const typeofValue = (value: unknown): string => {
|
||||||
if (
|
if (
|
||||||
value instanceof CodeModeFunction ||
|
value instanceof CodeModeFunction ||
|
||||||
|
value instanceof GeneratorMethodReference ||
|
||||||
value instanceof CoercionFunction ||
|
value instanceof CoercionFunction ||
|
||||||
value instanceof IntrinsicReference ||
|
value instanceof IntrinsicReference ||
|
||||||
value instanceof GlobalMethodReference ||
|
value instanceof GlobalMethodReference ||
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
|
||||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||||
import { spreadItems } from "./collections.js"
|
|
||||||
|
|
||||||
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
|
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -53,17 +54,6 @@ export const mathMethods = new Set([
|
||||||
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||||
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
|
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
|
||||||
if (name === "random") return Math.random()
|
if (name === "random") return Math.random()
|
||||||
if (name === "sumPrecise") {
|
|
||||||
const items = spreadItems(args[0])
|
|
||||||
if (items === undefined) {
|
|
||||||
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable collection.", node).as("TypeError")
|
|
||||||
}
|
|
||||||
const numbers = Array.from(items)
|
|
||||||
if (!numbers.every((item): item is number => typeof item === "number")) {
|
|
||||||
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError")
|
|
||||||
}
|
|
||||||
return Math.sumPrecise(numbers)
|
|
||||||
}
|
|
||||||
// Validate only the arguments the method consumes; like JS, extras are ignored
|
// Validate only the arguments the method consumes; like JS, extras are ignored
|
||||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||||
const num = (index: number): number => {
|
const num = (index: number): number => {
|
||||||
|
|
@ -153,3 +143,29 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
|
throw new InterpreterRuntimeError(`Math.${name} is not available.`, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const invokeMathSumPrecise = <R>(
|
||||||
|
runner: SyncIteratorRunner<R>,
|
||||||
|
source: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<number, unknown, R> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cursor = yield* runner.syncIterator(source, node)
|
||||||
|
if (cursor === undefined) {
|
||||||
|
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node).as("TypeError")
|
||||||
|
}
|
||||||
|
const numbers: Array<number> = []
|
||||||
|
while (true) {
|
||||||
|
const step = yield* cursor.next
|
||||||
|
if (step.done) return Math.sumPrecise(numbers)
|
||||||
|
yield* preserveConsumerError(
|
||||||
|
cursor,
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (typeof step.value !== "number") {
|
||||||
|
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError")
|
||||||
|
}
|
||||||
|
numbers.push(step.value)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type AstNode,
|
type AstNode,
|
||||||
AsyncIteratorSymbol,
|
AsyncIteratorSymbol,
|
||||||
|
|
@ -7,8 +8,9 @@ import {
|
||||||
} from "../interpreter/model.js"
|
} from "../interpreter/model.js"
|
||||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||||
import { isBlockedMember } from "../tool-runtime.js"
|
import { isBlockedMember } from "../tool-runtime.js"
|
||||||
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||||
import { boundedData, coerceToString } from "./value.js"
|
import { boundedData, coerceToString } from "./value.js"
|
||||||
|
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
|
||||||
|
|
||||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||||
|
|
||||||
|
|
@ -39,11 +41,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||||
out[key] = item
|
out[key] = item
|
||||||
}
|
}
|
||||||
const addEntry = (out: Record<string, unknown>, key: unknown, item: unknown): void => {
|
|
||||||
boundedData(key, "Object.fromEntries key")
|
|
||||||
boundedData(item, "Object.fromEntries value")
|
|
||||||
guardedSet(out, coerceToString(key), item)
|
|
||||||
}
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "keys":
|
case "keys":
|
||||||
return Object.keys(requireObject())
|
return Object.keys(requireObject())
|
||||||
|
|
@ -79,32 +76,47 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
case "fromEntries": {
|
|
||||||
if (args[0] instanceof CodeModeMap) {
|
|
||||||
const out: Record<string, unknown> = Object.create(null)
|
|
||||||
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if (args[0] instanceof CodeModeURLSearchParams) {
|
|
||||||
const out: Record<string, unknown> = Object.create(null)
|
|
||||||
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
|
|
||||||
if (!Array.isArray(pairs)) {
|
|
||||||
boundedData(args[0], "Object.fromEntries input")
|
|
||||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
|
||||||
}
|
|
||||||
const out: Record<string, unknown> = Object.create(null)
|
|
||||||
for (const pair of pairs) {
|
|
||||||
const validated = boundedData(pair, "Object.fromEntries entry")
|
|
||||||
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
|
|
||||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
|
|
||||||
const entry = pair as Record<string, unknown>
|
|
||||||
addEntry(out, entry[0], entry[1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new InterpreterRuntimeError(`Object.${name} is not available.`, node)
|
throw new InterpreterRuntimeError(`Object.${name} is not available.`, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const invokeObjectFromEntries = <R>(
|
||||||
|
runner: SyncIteratorRunner<R>,
|
||||||
|
source: unknown,
|
||||||
|
node: AstNode,
|
||||||
|
): Effect.Effect<Record<string, unknown>, unknown, R> => {
|
||||||
|
const out: Record<string, unknown> = Object.create(null)
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const cursor = yield* runner.syncIterator(source, node)
|
||||||
|
if (cursor === undefined) {
|
||||||
|
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node).as(
|
||||||
|
"TypeError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
const step = yield* cursor.next
|
||||||
|
if (step.done) return out
|
||||||
|
yield* preserveConsumerError(
|
||||||
|
cursor,
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (
|
||||||
|
step.value === null ||
|
||||||
|
typeof step.value !== "object" ||
|
||||||
|
isCodeModeValue(step.value) ||
|
||||||
|
containsOpaqueReference(step.value)
|
||||||
|
) {
|
||||||
|
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
|
||||||
|
"TypeError",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const entry = step.value as Record<string, unknown>
|
||||||
|
boundedData(entry[0], "Object.fromEntries key")
|
||||||
|
boundedData(entry[1], "Object.fromEntries value")
|
||||||
|
const key = coerceToString(entry[0])
|
||||||
|
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||||
|
out[key] = entry[1]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
||||||
"## Language",
|
"## Language",
|
||||||
"",
|
"",
|
||||||
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
|
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
|
||||||
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
|
"Modules/imports, classes, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use tools for external operations. Use await with try/catch.",
|
||||||
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
|
||||||
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -732,9 +732,10 @@ describe("CodeMode public contract", () => {
|
||||||
expect(instructions).toContain("not a general-purpose runtime")
|
expect(instructions).toContain("not a general-purpose runtime")
|
||||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||||
expect(instructions).not.toContain("TypeScript type annotations")
|
expect(instructions).not.toContain("TypeScript type annotations")
|
||||||
for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) {
|
for (const missing of ["Modules/imports", "classes", "fetch"]) {
|
||||||
expect(instructions).toContain(missing)
|
expect(instructions).toContain(missing)
|
||||||
}
|
}
|
||||||
|
expect(instructions).not.toContain("generators")
|
||||||
expect(instructions).not.toContain("new Promise(...) are unavailable")
|
expect(instructions).not.toContain("new Promise(...) are unavailable")
|
||||||
expect(instructions).not.toContain("promise chaining")
|
expect(instructions).not.toContain("promise chaining")
|
||||||
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||||
|
|
|
||||||
1271
packages/codemode/test/generator-test262.test.ts
Normal file
1271
packages/codemode/test/generator-test262.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -946,7 +946,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||||
).toBe("TypeError")
|
).toBe("TypeError")
|
||||||
})
|
})
|
||||||
|
|
||||||
test.failing("Promise.resolve recursively assimilates callable thenables", async () => {
|
test("Promise.resolve recursively assimilates callable thenables", async () => {
|
||||||
// Source: test/built-ins/Promise/resolve/resolve-thenable.js
|
// Source: test/built-ins/Promise/resolve/resolve-thenable.js
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
|
|
@ -958,7 +958,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test.failing("Promise combinators assimilate callable thenable inputs", async () => {
|
test("Promise combinators assimilate callable thenable inputs", async () => {
|
||||||
// Sources:
|
// Sources:
|
||||||
// test/built-ins/Promise/all/reject-immed.js
|
// test/built-ins/Promise/all/reject-immed.js
|
||||||
// test/built-ins/Promise/all/reject-ignored-immed.js
|
// test/built-ins/Promise/all/reject-ignored-immed.js
|
||||||
|
|
@ -988,7 +988,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test.failing("await assimilates callable thenables", async () => {
|
test("await assimilates callable thenables", async () => {
|
||||||
// Source: test/language/expressions/await/await-awaits-thenables.js
|
// Source: test/language/expressions/await/await-awaits-thenables.js
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
|
|
@ -998,7 +998,7 @@ describe("Test262 expected Promise conformance", () => {
|
||||||
).toBe(42)
|
).toBe(42)
|
||||||
})
|
})
|
||||||
|
|
||||||
test.failing("await rejects when a callable thenable throws", async () => {
|
test("await rejects when a callable thenable throws", async () => {
|
||||||
// Source: test/language/expressions/await/await-awaits-thenables-that-throw.js
|
// Source: test/language/expressions/await/await-awaits-thenables-that-throw.js
|
||||||
expect(
|
expect(
|
||||||
await value(`
|
await value(`
|
||||||
|
|
@ -1013,6 +1013,94 @@ describe("Test262 expected Promise conformance", () => {
|
||||||
`),
|
`),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("thenable resolution is deferred and settles only once", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/resolve/S25.Promise_resolve_foreign_thenable_2.js
|
||||||
|
// test/built-ins/Promise/exception-after-resolve-in-thenable-job.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = []
|
||||||
|
const thenable = {
|
||||||
|
then: (resolve, reject) => {
|
||||||
|
sequence.push("then")
|
||||||
|
resolve(1)
|
||||||
|
reject(2)
|
||||||
|
throw 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const promise = Promise.resolve(thenable)
|
||||||
|
sequence.push("after resolve")
|
||||||
|
const result = await promise
|
||||||
|
sequence.push("after await")
|
||||||
|
return [result, sequence]
|
||||||
|
`),
|
||||||
|
).toEqual([1, ["after resolve", "then", "after await"]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the first thenable rejection wins over later resolution and throws", async () => {
|
||||||
|
// Source: test/built-ins/Promise/exception-after-resolve-in-thenable-job.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const thenable = {
|
||||||
|
then: (resolve, reject) => {
|
||||||
|
reject("first")
|
||||||
|
resolve("second")
|
||||||
|
throw "third"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await thenable
|
||||||
|
return "fulfilled"
|
||||||
|
} catch (reason) {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
).toBe("first")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("constructors, reactions, finally, and async returns assimilate thenables", async () => {
|
||||||
|
// Sources:
|
||||||
|
// test/built-ins/Promise/resolve-thenable-immed.js
|
||||||
|
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-thenable.js
|
||||||
|
// test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js
|
||||||
|
const thenable = (value: string) => `({ then: (resolve) => resolve(${JSON.stringify(value)}) })`
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const fromAsync = async () => ${thenable("async")}
|
||||||
|
const cleanup = []
|
||||||
|
return await Promise.all([
|
||||||
|
new Promise((resolve) => resolve(${thenable("constructor")})),
|
||||||
|
Promise.resolve().then(() => ${thenable("reaction")}),
|
||||||
|
Promise.resolve("kept").finally(() => {
|
||||||
|
cleanup.push("ran")
|
||||||
|
return ${thenable("ignored")}
|
||||||
|
}),
|
||||||
|
fromAsync(),
|
||||||
|
]).then((values) => [values, cleanup])
|
||||||
|
`),
|
||||||
|
).toEqual([["constructor", "reaction", "kept", "async"], ["ran"]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("finally settles after its cleanup thenable reactions", async () => {
|
||||||
|
// Source: test/built-ins/Promise/prototype/finally/resolved-observable-then-calls-PromiseResolve.js
|
||||||
|
expect(
|
||||||
|
await value(`
|
||||||
|
const sequence = []
|
||||||
|
const cleanup = { then: (resolve) => { sequence.push("then"); resolve() } }
|
||||||
|
const result = Promise.resolve("kept").finally(() => cleanup)
|
||||||
|
result.then(() => sequence.push("finally"))
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => sequence.push("tick1"))
|
||||||
|
.then(() => sequence.push("tick2"))
|
||||||
|
.then(() => sequence.push("tick3"))
|
||||||
|
.then(() => sequence.push("tick4"))
|
||||||
|
await result
|
||||||
|
sequence.push("await")
|
||||||
|
return sequence
|
||||||
|
`),
|
||||||
|
).toEqual(["tick1", "then", "tick2", "tick3", "tick4", "finally", "await"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Test262 Promise.any", () => {
|
describe("Test262 Promise.any", () => {
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,12 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||||
prepareTransport: (body) => Effect.succeed(body),
|
prepareTransport: (body) => Effect.succeed(body),
|
||||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||||
}
|
}
|
||||||
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
|
return Model.make({
|
||||||
|
id: info.modelID ?? info.id,
|
||||||
|
provider: info.providerID,
|
||||||
|
route,
|
||||||
|
compatibility: info.compatibility,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
|
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
|
||||||
|
|
|
||||||
33
packages/core/src/app.ts
Normal file
33
packages/core/src/app.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export * as App from "./app"
|
||||||
|
|
||||||
|
import { Context, Layer } from "effect"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
readonly name: string
|
||||||
|
readonly version: string
|
||||||
|
readonly channel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Metadata = Context.Reference<Info>("@opencode/App", {
|
||||||
|
defaultValue: () => make(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function make(input: Partial<Info> = {}): Info {
|
||||||
|
return {
|
||||||
|
name: input.name ?? "opencode",
|
||||||
|
version: input.version ?? "unknown",
|
||||||
|
channel: input.channel ?? "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useragent(app: Info) {
|
||||||
|
return `opencode/${app.channel}/${app.version}/${app.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = (input?: Partial<Info>) => Layer.succeed(Metadata, make(input))
|
||||||
|
|
||||||
|
export const configured = (input?: Partial<Info>) =>
|
||||||
|
makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] })
|
||||||
|
|
||||||
|
export const node = configured()
|
||||||
|
|
@ -30,6 +30,7 @@ import { ConfigReference } from "./config/reference"
|
||||||
import { ConfigToolOutput } from "./config/tool-output"
|
import { ConfigToolOutput } from "./config/tool-output"
|
||||||
import { ConfigVariable } from "./config/variable"
|
import { ConfigVariable } from "./config/variable"
|
||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigWatcher } from "./config/watcher"
|
||||||
|
import { ConfigWarming } from "./config/warming"
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||||
import { WellKnown } from "./wellknown"
|
import { WellKnown } from "./wellknown"
|
||||||
|
|
@ -110,6 +111,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
description: "Ordered plugin enablement directives and external package declarations",
|
||||||
}),
|
}),
|
||||||
|
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||||
|
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||||
|
}),
|
||||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ModelV2 } from "../../model"
|
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
|
|
@ -59,6 +58,8 @@ export const Plugin = define({
|
||||||
if (config.family !== undefined) model.family = config.family
|
if (config.family !== undefined) model.family = config.family
|
||||||
if (config.name !== undefined) model.name = config.name
|
if (config.name !== undefined) model.name = config.name
|
||||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||||
|
if (config.compatibility !== undefined)
|
||||||
|
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||||
if (config.package !== undefined) model.package = config.package
|
if (config.package !== undefined) model.package = config.package
|
||||||
if (config.settings !== undefined)
|
if (config.settings !== undefined)
|
||||||
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
|
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||||
modelID: ModelV2.ID.pipe(Schema.optional),
|
modelID: ModelV2.ID.pipe(Schema.optional),
|
||||||
family: ModelV2.Family.pipe(Schema.optional),
|
family: ModelV2.Family.pipe(Schema.optional),
|
||||||
name: Schema.String.pipe(Schema.optional),
|
name: Schema.String.pipe(Schema.optional),
|
||||||
|
compatibility: ModelV2.Compatibility.pipe(Schema.optional),
|
||||||
package: Schema.String.pipe(Schema.optional),
|
package: Schema.String.pipe(Schema.optional),
|
||||||
...Overlays,
|
...Overlays,
|
||||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||||
|
|
|
||||||
17
packages/core/src/config/warming.ts
Normal file
17
packages/core/src/config/warming.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export * as ConfigWarming from "./warming"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export class Info extends Schema.Class<Info>("ConfigV2.Warming")({
|
||||||
|
prompt: Schema.String.pipe(Schema.optional).annotate({
|
||||||
|
description: "Prompt sent for keep-alive requests",
|
||||||
|
}),
|
||||||
|
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||||
|
description: 'Idle time between keep-alive requests (default: "4 minutes")',
|
||||||
|
}),
|
||||||
|
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||||
|
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Warming = Schema.Union([Schema.Boolean, Info])
|
||||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -55,5 +55,6 @@ export const migrations = (
|
||||||
import("./migration/20260709190621_session_pending_table"),
|
import("./migration/20260709190621_session_pending_table"),
|
||||||
import("./migration/20260710025429_instruction_sync"),
|
import("./migration/20260710025429_instruction_sync"),
|
||||||
import("./migration/20260716020354_kv"),
|
import("./migration/20260716020354_kv"),
|
||||||
|
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260722011141_delete_tool_progress_events",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`DELETE FROM \`event\` WHERE \`type\` = 'session.tool.progress.1';`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
|
|
@ -17,6 +17,25 @@ export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||||
commonDirectory: AbsolutePath,
|
commonDirectory: AbsolutePath,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
// Included from $GIT_DIR/config via include.path (git >= 1.7.10); OpenCode owns
|
||||||
|
// this file entirely, so updates are plain rewrites with no config parsing.
|
||||||
|
const snapshotConfigFile = "opencode.gitconfig"
|
||||||
|
const snapshotConfigInclude = `[include]
|
||||||
|
path = ${snapshotConfigFile}
|
||||||
|
`
|
||||||
|
const snapshotConfig = `[core]
|
||||||
|
autocrlf = false
|
||||||
|
longpaths = true
|
||||||
|
symlinks = true
|
||||||
|
fsmonitor = false
|
||||||
|
untrackedCache = true
|
||||||
|
[feature]
|
||||||
|
manyFiles = true
|
||||||
|
[index]
|
||||||
|
version = 4
|
||||||
|
threads = true
|
||||||
|
`
|
||||||
|
|
||||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
||||||
export type ChangeSet = typeof ChangeSet.Type
|
export type ChangeSet = typeof ChangeSet.Type
|
||||||
|
|
||||||
|
|
@ -376,19 +395,24 @@ const layer = Layer.effect(
|
||||||
commonDirectory: input.gitDirectory,
|
commonDirectory: input.gitDirectory,
|
||||||
})
|
})
|
||||||
yield* repositoryOperation("create", repository, ["init"])
|
yield* repositoryOperation("create", repository, ["init"])
|
||||||
yield* Effect.forEach(
|
yield* Effect.gen(function* () {
|
||||||
[
|
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
|
||||||
["core.autocrlf", "false"],
|
const config = path.join(input.gitDirectory, "config")
|
||||||
["core.longpaths", "true"],
|
const current = yield* fs.readFileString(config)
|
||||||
["core.symlinks", "true"],
|
if (current.includes(snapshotConfigInclude)) return
|
||||||
["core.fsmonitor", "false"],
|
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
|
||||||
["feature.manyFiles", "true"],
|
flag: "a",
|
||||||
["index.version", "4"],
|
})
|
||||||
["index.threads", "true"],
|
}).pipe(
|
||||||
["core.untrackedCache", "true"],
|
Effect.mapError(
|
||||||
],
|
(cause) =>
|
||||||
([key, value]) => repositoryOperation("create", repository, ["config", key, value]),
|
new OperationError({
|
||||||
{ discard: true },
|
operation: "create",
|
||||||
|
directory: input.gitDirectory,
|
||||||
|
message: "Failed to configure Git storage",
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (!input.seed) return repository
|
if (!input.seed) return repository
|
||||||
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
|
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
|
||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]),
|
reason: Schema.Literal("non_directory_ancestor"),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
|
|
@ -83,7 +83,6 @@ const layer = Layer.effect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const locationRoot = yield* fs.realPath(location.directory)
|
|
||||||
|
|
||||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
|
|
@ -124,14 +123,8 @@ const layer = Layer.effect(
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||||
|
|
||||||
const resolved = yield* resolvePath(absolute)
|
const resolved = yield* resolvePath(absolute)
|
||||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
|
||||||
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const external = !lexicallyInternal
|
const external = !lexicallyInternal
|
||||||
const resource = external
|
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||||
? slash(resolved.canonical)
|
|
||||||
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
|
||||||
const externalDirectory =
|
const externalDirectory =
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ElicitationCompleteNotificationSchema,
|
ElicitationCompleteNotificationSchema,
|
||||||
ElicitRequestSchema,
|
ElicitRequestSchema,
|
||||||
GetPromptResultSchema,
|
GetPromptResultSchema,
|
||||||
|
type Implementation,
|
||||||
type ElicitRequestFormParams,
|
type ElicitRequestFormParams,
|
||||||
type ElicitRequestParams,
|
type ElicitRequestParams,
|
||||||
type ElicitRequestURLParams,
|
type ElicitRequestURLParams,
|
||||||
|
|
@ -29,7 +30,6 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
import { Cause, Effect, Exit, Schema } from "effect"
|
||||||
import { ConfigMCP } from "../config/mcp"
|
import { ConfigMCP } from "../config/mcp"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
|
||||||
|
|
||||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||||
|
|
@ -185,6 +185,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||||
authProvider?: OAuthClientProvider,
|
authProvider?: OAuthClientProvider,
|
||||||
elicitation?: ElicitationHandler,
|
elicitation?: ElicitationHandler,
|
||||||
|
clientInfo: Implementation = { name: "opencode", version: "unknown" },
|
||||||
) {
|
) {
|
||||||
const transport: Transport = yield* Effect.gen(function* () {
|
const transport: Transport = yield* Effect.gen(function* () {
|
||||||
if (config.type === "local") {
|
if (config.type === "local") {
|
||||||
|
|
@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
{ name: "opencode", version: InstallationVersion },
|
clientInfo,
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,17 @@ export interface Interface {
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const Options = Schema.Struct({
|
||||||
|
clientInfo: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
name: Schema.String,
|
||||||
|
version: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
export type Options = typeof Options.Type
|
||||||
|
|
||||||
|
export const layer = (options?: Options) => Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
|
@ -498,7 +508,14 @@ export const layer = Layer.effect(
|
||||||
const authProvider = yield* connectProvider(entry)
|
const authProvider = yield* connectProvider(entry)
|
||||||
// List tools as part of connect so a failure here marks the server failed rather than
|
// List tools as part of connect so a failure here marks the server failed rather than
|
||||||
// leaving it connected with a silently empty tool list and no path to recover.
|
// leaving it connected with a silently empty tool list and no path to recover.
|
||||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
|
const result = yield* MCPClient.connect(
|
||||||
|
name,
|
||||||
|
entry.config,
|
||||||
|
location.directory,
|
||||||
|
authProvider,
|
||||||
|
elicitation,
|
||||||
|
options?.clientInfo,
|
||||||
|
).pipe(
|
||||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||||
Scope.provide(scope),
|
Scope.provide(scope),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
|
|
@ -772,11 +789,15 @@ export const layer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export function configured(options?: Options) {
|
||||||
service: Service,
|
return makeLocationNode({
|
||||||
layer,
|
service: Service,
|
||||||
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
layer: layer(options),
|
||||||
})
|
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const node = configured()
|
||||||
|
|
||||||
// Schema `optional` strips undefined-valued properties on encode, so fields can assign
|
// Schema `optional` strips undefined-valued properties on encode, so fields can assign
|
||||||
// optional properties directly instead of conditionally spreading them.
|
// optional properties directly instead of conditionally spreading them.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ export type VariantID = typeof VariantID.Type
|
||||||
export const Family = Model.Family
|
export const Family = Model.Family
|
||||||
export type Family = Model.Family
|
export type Family = Model.Family
|
||||||
|
|
||||||
|
export const ReasoningField = Model.ReasoningField
|
||||||
|
export type ReasoningField = Model.ReasoningField
|
||||||
|
|
||||||
|
export const Compatibility = Model.Compatibility
|
||||||
|
export type Compatibility = Model.Compatibility
|
||||||
|
|
||||||
export const Capabilities = Model.Capabilities
|
export const Capabilities = Model.Capabilities
|
||||||
export type Capabilities = Model.Capabilities
|
export type Capabilities = Model.Capabilities
|
||||||
|
|
||||||
|
|
@ -25,6 +31,12 @@ export type Info = Model.Info
|
||||||
|
|
||||||
export type MutableInfo = DeepMutable<Info>
|
export type MutableInfo = DeepMutable<Info>
|
||||||
|
|
||||||
|
export function compatibility(input: unknown): Compatibility | undefined {
|
||||||
|
if (typeof input === "string") return { reasoningField: input }
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
|
||||||
|
return typeof input.field === "string" ? { reasoningField: input.field } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||||
const [providerID, ...modelID] = input.split("/")
|
const [providerID, ...modelID] = input.split("/")
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Client } from "@opencode-ai/util/client"
|
import { App } from "./app"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Flock } from "@opencode-ai/util/flock"
|
import { Flock } from "@opencode-ai/util/flock"
|
||||||
import { Hash } from "@opencode-ai/util/hash"
|
import { Hash } from "@opencode-ai/util/hash"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
|
|
@ -44,7 +43,7 @@ type SourceModel = {
|
||||||
readonly reasoning_options?: readonly ReasoningOption[]
|
readonly reasoning_options?: readonly ReasoningOption[]
|
||||||
readonly temperature?: boolean
|
readonly temperature?: boolean
|
||||||
readonly tool_call: boolean
|
readonly tool_call: boolean
|
||||||
readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" }
|
readonly interleaved?: boolean | string | { readonly field: string }
|
||||||
readonly cost?: Cost
|
readonly cost?: Cost
|
||||||
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
||||||
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
|
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
|
||||||
|
|
@ -496,6 +495,7 @@ function modelInfo(
|
||||||
modelID: ModelV2.ID.make(model.id),
|
modelID: ModelV2.ID.make(model.id),
|
||||||
providerID,
|
providerID,
|
||||||
name: input.name ?? model.name,
|
name: input.name ?? model.name,
|
||||||
|
compatibility: ModelV2.compatibility(model.interleaved),
|
||||||
family: model.family ? ModelV2.Family.make(model.family) : undefined,
|
family: model.family ? ModelV2.Family.make(model.family) : undefined,
|
||||||
package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined,
|
package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined,
|
||||||
settings: model.provider?.api ? { baseURL: model.provider.api } : undefined,
|
settings: model.provider?.api ? { baseURL: model.provider.api } : undefined,
|
||||||
|
|
@ -543,7 +543,7 @@ export const layer = (options?: Options) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const client = yield* Client.Name
|
const app = yield* App.Metadata
|
||||||
const http = HttpClient.filterStatusOk(
|
const http = HttpClient.filterStatusOk(
|
||||||
(yield* HttpClient.HttpClient).pipe(
|
(yield* HttpClient.HttpClient).pipe(
|
||||||
HttpClient.retryTransient({
|
HttpClient.retryTransient({
|
||||||
|
|
@ -556,7 +556,7 @@ export const layer = (options?: Options) =>
|
||||||
|
|
||||||
const source = options?.url || "https://models.dev"
|
const source = options?.url || "https://models.dev"
|
||||||
const fetch = options?.fetch ?? true
|
const fetch = options?.fetch ?? true
|
||||||
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
|
const userAgent = App.useragent(app)
|
||||||
const filepath = path.join(
|
const filepath = path.join(
|
||||||
Global.Path.cache,
|
Global.Path.cache,
|
||||||
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||||
|
|
@ -660,7 +660,7 @@ export function configured(options?: Options) {
|
||||||
return makeGlobalNode({
|
return makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer(options),
|
layer: layer(options),
|
||||||
deps: [FSUtil.node, EventV2.node, Client.node, httpClient],
|
deps: [FSUtil.node, EventV2.node, App.node, httpClient],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin"
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
|
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { App } from "./app"
|
||||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { AISDK } from "./aisdk"
|
import { AISDK } from "./aisdk"
|
||||||
|
|
@ -144,6 +145,7 @@ export const node = makeLocationNode({
|
||||||
layer,
|
layer,
|
||||||
deps: [
|
deps: [
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
|
App.node,
|
||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
AISDK.node,
|
AISDK.node,
|
||||||
Catalog.node,
|
Catalog.node,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export * as PluginHost from "./host"
|
||||||
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
import { App } from "../app"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { AISDK } from "../aisdk"
|
import { AISDK } from "../aisdk"
|
||||||
|
|
@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||||
|
const app = yield* App.Metadata
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|
@ -61,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
app,
|
||||||
options: {},
|
options: {},
|
||||||
agent: {
|
agent: {
|
||||||
get: (id) => agents.get(AgentV2.ID.make(id)),
|
get: (id) => agents.get(AgentV2.ID.make(id)),
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import { PluginRuntime } from "./runtime"
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { SystemPromptPlugin } from "./system-prompt"
|
import { SystemPromptPlugin } from "./system-prompt"
|
||||||
import { VariantPlugin } from "./variant"
|
import { VariantPlugin } from "./variant"
|
||||||
|
import { WarmingPlugin } from "./warming"
|
||||||
import { WellKnownPlugin } from "../wellknown/plugin"
|
import { WellKnownPlugin } from "../wellknown/plugin"
|
||||||
|
|
||||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
|
|
@ -143,6 +144,7 @@ const pre = [
|
||||||
WebFetchTool.Plugin,
|
WebFetchTool.Plugin,
|
||||||
WebSearchTool.Plugin,
|
WebSearchTool.Plugin,
|
||||||
WriteTool.Plugin,
|
WriteTool.Plugin,
|
||||||
|
WarmingPlugin.Plugin,
|
||||||
] as const satisfies readonly InternalPlugin[]
|
] as const satisfies readonly InternalPlugin[]
|
||||||
|
|
||||||
const post = [
|
const post = [
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
agent: {
|
agent: {
|
||||||
get: (id) => run(host.agent.get(id)),
|
get: (id) => run(host.agent.get(id)),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { App } from "../../app"
|
||||||
import { Effect, Option, Schema } from "effect"
|
import { Effect, Option, Schema } from "effect"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({
|
||||||
accountId: config.accountId,
|
accountId: config.accountId,
|
||||||
gateway: config.gatewayId,
|
gateway: config.gatewayId,
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
options: gatewayOptions(evt.options, metadata),
|
options: gatewayOptions(evt.options, metadata, ctx.app),
|
||||||
} as any)
|
} as any)
|
||||||
const unified = createUnified({ apiKey: config.apiKey })
|
const unified = createUnified({ apiKey: config.apiKey })
|
||||||
evt.sdk = {
|
evt.sdk = {
|
||||||
|
|
@ -64,7 +64,7 @@ function gatewayMetadata(options: Record<string, unknown>) {
|
||||||
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
|
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
|
function gatewayOptions(options: Record<string, unknown>, metadata: unknown, app: App.Info) {
|
||||||
return {
|
return {
|
||||||
metadata,
|
metadata,
|
||||||
cacheTtl: options.cacheTtl,
|
cacheTtl: options.cacheTtl,
|
||||||
|
|
@ -72,7 +72,7 @@ function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
|
||||||
skipCache: options.skipCache,
|
skipCache: options.skipCache,
|
||||||
collectLog: options.collectLog,
|
collectLog: options.collectLog,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { App } from "../../app"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({
|
||||||
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
||||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||||
evt.sdk = mod.createOpenAICompatible(
|
evt.sdk = mod.createOpenAICompatible(
|
||||||
sdkOptions({
|
sdkOptions(
|
||||||
...evt.options,
|
{
|
||||||
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
...evt.options,
|
||||||
}) as any,
|
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
||||||
|
},
|
||||||
|
ctx.app,
|
||||||
|
) as any,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: {
|
||||||
return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdkOptions(options: Record<string, any>) {
|
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
baseURL: expandAccountId(options.baseURL),
|
baseURL: expandAccountId(options.baseURL),
|
||||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
name: providerID,
|
name: providerID,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { CopilotModels } from "../../github-copilot/models"
|
import { CopilotModels } from "../../github-copilot/models"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { App } from "../../app"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
|
@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal"
|
||||||
|
|
||||||
const clientID = "Ov23li8tweQw6odWQebz"
|
const clientID = "Ov23li8tweQw6odWQebz"
|
||||||
const apiVersion = "2026-06-01"
|
const apiVersion = "2026-06-01"
|
||||||
|
const userApiVersion = "2025-04-01"
|
||||||
const pollingSafetyMargin = 3000
|
const pollingSafetyMargin = 3000
|
||||||
const methodID = Integration.MethodID.make("device")
|
const methodID = Integration.MethodID.make("device")
|
||||||
|
|
||||||
|
|
@ -27,10 +28,18 @@ const Token = Schema.Struct({
|
||||||
error: Schema.optional(Schema.String),
|
error: Schema.optional(Schema.String),
|
||||||
interval: Schema.optional(Schema.Number),
|
interval: Schema.optional(Schema.Number),
|
||||||
})
|
})
|
||||||
|
const User = Schema.Struct({
|
||||||
|
endpoints: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
api: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const decodeUser = Schema.decodeUnknownOption(User)
|
||||||
const JsonBody = Schema.UnknownFromJsonString
|
const JsonBody = Schema.UnknownFromJsonString
|
||||||
const decodeBody = Schema.decodeUnknownOption(JsonBody)
|
const decodeBody = Schema.decodeUnknownOption(JsonBody)
|
||||||
|
|
||||||
const oauth = {
|
const oauth = (app: App.Info) => ({
|
||||||
integrationID: Integration.ID.make("github-copilot"),
|
integrationID: Integration.ID.make("github-copilot"),
|
||||||
method: {
|
method: {
|
||||||
id: methodID,
|
id: methodID,
|
||||||
|
|
@ -63,7 +72,7 @@ const oauth = {
|
||||||
const urls = oauthURLs(domain)
|
const urls = oauthURLs(domain)
|
||||||
const device = yield* request(urls.device, {
|
const device = yield* request(urls.device, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
|
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
|
||||||
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
|
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
|
||||||
const interval = Math.max(device.interval, 1) * 1000
|
const interval = Math.max(device.interval, 1) * 1000
|
||||||
|
|
@ -71,7 +80,7 @@ const oauth = {
|
||||||
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
|
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
|
||||||
request(urls.token, {
|
request(urls.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: clientID,
|
client_id: clientID,
|
||||||
device_code: device.device_code,
|
device_code: device.device_code,
|
||||||
|
|
@ -81,15 +90,35 @@ const oauth = {
|
||||||
Effect.map(Schema.decodeUnknownSync(Token)),
|
Effect.map(Schema.decodeUnknownSync(Token)),
|
||||||
Effect.flatMap((token) => {
|
Effect.flatMap((token) => {
|
||||||
if (token.access_token) {
|
if (token.access_token) {
|
||||||
return Effect.succeed(
|
const access = token.access_token
|
||||||
Credential.OAuth.make({
|
return request(
|
||||||
type: "oauth",
|
`${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
|
||||||
methodID,
|
{
|
||||||
refresh: token.access_token,
|
headers: {
|
||||||
access: token.access_token,
|
Accept: "application/json",
|
||||||
expires: 0,
|
Authorization: `Bearer ${access}`,
|
||||||
...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}),
|
"User-Agent": App.useragent(app),
|
||||||
}),
|
"X-GitHub-Api-Version": userApiVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
|
||||||
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
|
Effect.map((apiEndpoint) =>
|
||||||
|
Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID,
|
||||||
|
refresh: access,
|
||||||
|
access,
|
||||||
|
expires: 0,
|
||||||
|
...((enterprise || apiEndpoint) && {
|
||||||
|
metadata: {
|
||||||
|
...(enterprise ? { enterpriseUrl: domain } : {}),
|
||||||
|
...(apiEndpoint ? { apiEndpoint } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (token.error === "authorization_pending")
|
if (token.error === "authorization_pending")
|
||||||
|
|
@ -109,7 +138,7 @@ const oauth = {
|
||||||
callback: poll(interval),
|
callback: poll(interval),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
function shouldUseResponses(modelID: string) {
|
function shouldUseResponses(modelID: string) {
|
||||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||||
|
|
@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterprise = credential.metadata?.enterpriseUrl
|
loaded.baseURL = copilotBaseURL(credential.metadata)
|
||||||
loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined)
|
|
||||||
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
|
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
|
||||||
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
|
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
|
||||||
loaded.models = yield* Effect.tryPromise({
|
loaded.models = yield* Effect.tryPromise({
|
||||||
|
|
@ -152,7 +180,7 @@ export const GithubCopilotPlugin = define({
|
||||||
{
|
{
|
||||||
...provider?.headers,
|
...provider?.headers,
|
||||||
Authorization: `Bearer ${credential.refresh}`,
|
Authorization: `Bearer ${credential.refresh}`,
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
"User-Agent": App.useragent(ctx.app),
|
||||||
"X-GitHub-Api-Version": apiVersion,
|
"X-GitHub-Api-Version": apiVersion,
|
||||||
},
|
},
|
||||||
existing,
|
existing,
|
||||||
|
|
@ -166,7 +194,7 @@ export const GithubCopilotPlugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
draft.method.update(oauth)
|
draft.method.update(oauth(ctx.app))
|
||||||
})
|
})
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||||
|
|
@ -209,6 +237,7 @@ export const GithubCopilotPlugin = define({
|
||||||
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
|
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
|
||||||
evt.options.fetch,
|
evt.options.fetch,
|
||||||
evt.package === "@ai-sdk/anthropic",
|
evt.package === "@ai-sdk/anthropic",
|
||||||
|
ctx.app,
|
||||||
)
|
)
|
||||||
if (evt.package === "@ai-sdk/anthropic") {
|
if (evt.package === "@ai-sdk/anthropic") {
|
||||||
evt.options.headers = {
|
evt.options.headers = {
|
||||||
|
|
@ -261,11 +290,18 @@ function baseURL(enterprise?: string) {
|
||||||
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
|
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
function headers() {
|
export function copilotBaseURL(metadata?: Readonly<Record<string, unknown>>) {
|
||||||
|
const endpoint = metadata?.apiEndpoint
|
||||||
|
if (typeof endpoint === "string" && endpoint) return endpoint
|
||||||
|
const enterprise = metadata?.enterpriseUrl
|
||||||
|
return baseURL(typeof enterprise === "string" ? enterprise : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(app: App.Info) {
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
"User-Agent": App.useragent(app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +318,12 @@ function request(url: string, init: RequestInit) {
|
||||||
|
|
||||||
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch {
|
export function copilotFetch(
|
||||||
|
token: string | undefined,
|
||||||
|
upstream: Fetch | undefined,
|
||||||
|
anthropic: boolean,
|
||||||
|
app: App.Info,
|
||||||
|
): Fetch {
|
||||||
const send = upstream ?? fetch
|
const send = upstream ?? fetch
|
||||||
return async (input, init) => {
|
return async (input, init) => {
|
||||||
const requestHeaders = new Headers(init?.headers)
|
const requestHeaders = new Headers(init?.headers)
|
||||||
|
|
@ -291,7 +332,7 @@ export function copilotFetch(token: string | undefined, upstream: Fetch | undefi
|
||||||
requestHeaders.delete("x-api-key")
|
requestHeaders.delete("x-api-key")
|
||||||
requestHeaders.set("Authorization", `Bearer ${token}`)
|
requestHeaders.set("Authorization", `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`)
|
requestHeaders.set("User-Agent", App.useragent(app))
|
||||||
requestHeaders.set("Openai-Intent", "conversation-edits")
|
requestHeaders.set("Openai-Intent", "conversation-edits")
|
||||||
requestHeaders.set("X-GitHub-Api-Version", apiVersion)
|
requestHeaders.set("X-GitHub-Api-Version", apiVersion)
|
||||||
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { App } from "../../app"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
@ -20,7 +20,7 @@ export const GitLabPlugin = define({
|
||||||
: (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
|
: (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
|
||||||
apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
|
apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
|
||||||
aiGatewayHeaders: {
|
aiGatewayHeaders: {
|
||||||
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `${App.useragent(ctx.app)} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
"anthropic-beta": "context-1m-2025-08-07",
|
"anthropic-beta": "context-1m-2025-08-07",
|
||||||
...evt.options.aiGatewayHeaders,
|
...evt.options.aiGatewayHeaders,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import { createServer } from "node:http"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||||
|
import { App } from "../../app"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
|
|
@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString(
|
||||||
)
|
)
|
||||||
const decodeClaims = Schema.decodeUnknownOption(Claims)
|
const decodeClaims = Schema.decodeUnknownOption(Claims)
|
||||||
|
|
||||||
const browser = {
|
const browser = (app: App.Info) => ({
|
||||||
integrationID: Integration.ID.make("openai"),
|
integrationID: Integration.ID.make("openai"),
|
||||||
method: {
|
method: {
|
||||||
id: browserMethodID,
|
id: browserMethodID,
|
||||||
|
|
@ -91,15 +91,15 @@ const browser = {
|
||||||
url: authorizeURL(redirect, pkce, state),
|
url: authorizeURL(redirect, pkce, state),
|
||||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
callback: Deferred.await(code).pipe(
|
callback: Deferred.await(code).pipe(
|
||||||
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
|
||||||
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
refresh: (value) => refresh(browserMethodID, value),
|
refresh: (value) => refresh(browserMethodID, value, app),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
const headless = {
|
const headless = (app: App.Info) => ({
|
||||||
integrationID: Integration.ID.make("openai"),
|
integrationID: Integration.ID.make("openai"),
|
||||||
method: {
|
method: {
|
||||||
id: headlessMethodID,
|
id: headlessMethodID,
|
||||||
|
|
@ -112,7 +112,7 @@ const headless = {
|
||||||
`${issuer}/api/accounts/deviceauth/usercode`,
|
`${issuer}/api/accounts/deviceauth/usercode`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers("application/json"),
|
headers: headers("application/json", app),
|
||||||
body: JSON.stringify({ client_id: clientID }),
|
body: JSON.stringify({ client_id: clientID }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -127,7 +127,7 @@ const headless = {
|
||||||
try: (signal) =>
|
try: (signal) =>
|
||||||
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers("application/json"),
|
headers: headers("application/json", app),
|
||||||
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
|
|
@ -140,10 +140,12 @@ const headless = {
|
||||||
}
|
}
|
||||||
return credential(
|
return credential(
|
||||||
headlessMethodID,
|
headlessMethodID,
|
||||||
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
yield* exchange(
|
||||||
verifier: data.code_verifier,
|
data.authorization_code,
|
||||||
challenge: "",
|
`${issuer}/deviceauth/callback`,
|
||||||
}),
|
{ verifier: data.code_verifier, challenge: "" },
|
||||||
|
app,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (response.status !== 403 && response.status !== 404) {
|
if (response.status !== 403 && response.status !== 404) {
|
||||||
|
|
@ -154,8 +156,8 @@ const headless = {
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
refresh: (value) => refresh(headlessMethodID, value),
|
refresh: (value) => refresh(headlessMethodID, value, app),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "opencode.provider.openai",
|
id: "opencode.provider.openai",
|
||||||
|
|
@ -173,8 +175,8 @@ export const OpenAIPlugin = define({
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
draft.method.update(browser)
|
draft.method.update(browser(ctx.app))
|
||||||
draft.method.update(headless)
|
draft.method.update(headless(ctx.app))
|
||||||
})
|
})
|
||||||
yield* load()
|
yield* load()
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
|
|
@ -232,14 +234,14 @@ export const OpenAIPlugin = define({
|
||||||
}),
|
}),
|
||||||
} satisfies PluginInternal.InternalPlugin)
|
} satisfies PluginInternal.InternalPlugin)
|
||||||
|
|
||||||
function headers(contentType: string) {
|
function headers(contentType: string, app: App.Info) {
|
||||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
return { "Content-Type": contentType, "User-Agent": App.useragent(app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function exchange(code: string, redirect: string, pkce: Pkce) {
|
function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) {
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
headers: headers("application/x-www-form-urlencoded", app),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code,
|
code,
|
||||||
|
|
@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
|
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">, app: App.Info) {
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
headers: headers("application/x-www-form-urlencoded", app),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: value.refresh,
|
refresh_token: value.refresh,
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||||
if (config.family !== undefined) model.family = config.family
|
if (config.family !== undefined) model.family = config.family
|
||||||
if (config.name !== undefined) model.name = config.name
|
if (config.name !== undefined) model.name = config.name
|
||||||
if (config.id !== undefined) model.modelID = config.id
|
if (config.id !== undefined) model.modelID = config.id
|
||||||
|
model.compatibility = ModelV2.compatibility(config.interleaved) ?? model.compatibility
|
||||||
if (config.provider !== undefined) {
|
if (config.provider !== undefined) {
|
||||||
model.package = config.provider.npm ? ProviderV2.aisdk(config.provider.npm) : undefined
|
model.package = config.provider.npm ? ProviderV2.aisdk(config.provider.npm) : undefined
|
||||||
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
|
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { createServer } from "node:http"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
import { Clock, Deferred, Effect, Option, Schema } from "effect"
|
||||||
|
import { App } from "../../app"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
@ -48,7 +48,7 @@ const DeviceError = Schema.Struct({
|
||||||
})
|
})
|
||||||
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
|
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
|
||||||
|
|
||||||
const browser = {
|
const browser = (app: App.Info) => ({
|
||||||
integrationID: Integration.ID.make("xai"),
|
integrationID: Integration.ID.make("xai"),
|
||||||
method: {
|
method: {
|
||||||
id: browserMethodID,
|
id: browserMethodID,
|
||||||
|
|
@ -108,15 +108,15 @@ const browser = {
|
||||||
url: authorizeURL(pkce, state, randomString(32)),
|
url: authorizeURL(pkce, state, randomString(32)),
|
||||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
callback: Deferred.await(code).pipe(
|
callback: Deferred.await(code).pipe(
|
||||||
Effect.flatMap((value) => exchange(value, pkce)),
|
Effect.flatMap((value) => exchange(value, pkce, app)),
|
||||||
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
|
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
|
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
const device = {
|
const device = (app: App.Info) => ({
|
||||||
integrationID: Integration.ID.make("xai"),
|
integrationID: Integration.ID.make("xai"),
|
||||||
method: {
|
method: {
|
||||||
id: deviceMethodID,
|
id: deviceMethodID,
|
||||||
|
|
@ -128,7 +128,7 @@ const device = {
|
||||||
`${issuer}/device/code`,
|
`${issuer}/device/code`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
|
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
|
||||||
},
|
},
|
||||||
Device,
|
Device,
|
||||||
|
|
@ -142,14 +142,14 @@ const device = {
|
||||||
url: value.verification_uri_complete ?? value.verification_uri,
|
url: value.verification_uri_complete ?? value.verification_uri,
|
||||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||||
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
|
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
|
||||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
}) satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
export const XAIPlugin = define({
|
export const XAIPlugin = define({
|
||||||
id: "opencode.provider.xai",
|
id: "opencode.provider.xai",
|
||||||
|
|
@ -158,8 +158,8 @@ export const XAIPlugin = define({
|
||||||
draft.update("xai", (integration) => {
|
draft.update("xai", (integration) => {
|
||||||
integration.name = "xAI"
|
integration.name = "xAI"
|
||||||
})
|
})
|
||||||
draft.method.update(browser)
|
draft.method.update(browser(ctx.app))
|
||||||
draft.method.update(device)
|
draft.method.update(device(ctx.app))
|
||||||
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||||
})
|
})
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
|
|
@ -180,12 +180,12 @@ export const XAIPlugin = define({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function exchange(code: string, pkce: Pkce) {
|
function exchange(code: string, pkce: Pkce, app: App.Info) {
|
||||||
return request(
|
return request(
|
||||||
`${issuer}/token`,
|
`${issuer}/token`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code,
|
code,
|
||||||
|
|
@ -198,12 +198,12 @@ function exchange(code: string, pkce: Pkce) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) {
|
||||||
return request(
|
return request(
|
||||||
`${issuer}/token`,
|
`${issuer}/token`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: value.refresh,
|
refresh_token: value.refresh,
|
||||||
|
|
@ -214,7 +214,7 @@ function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
|
||||||
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
|
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, unknown> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const started = yield* Clock.currentTimeMillis
|
const started = yield* Clock.currentTimeMillis
|
||||||
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
|
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
|
||||||
|
|
@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
|
||||||
}
|
}
|
||||||
const response = yield* send(`${issuer}/token`, {
|
const response = yield* send(`${issuer}/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(),
|
headers: headers(app),
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: deviceGrant,
|
grant_type: deviceGrant,
|
||||||
client_id: clientID,
|
client_id: clientID,
|
||||||
|
|
@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) {
|
||||||
return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
|
return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
function headers() {
|
function headers(app: App.Info) {
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
"User-Agent": App.useragent(app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
export * as SkillPlugin from "./skill"
|
export * as SkillPlugin from "./skill"
|
||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define, type Context } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
|
@ -27,7 +26,7 @@ const REPORT_DESCRIPTION =
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "opencode.skill",
|
id: "opencode.skill",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const reportContent = yield* reportContentWithDiagnostics()
|
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
|
||||||
yield* ctx.skill.transform((draft) => {
|
yield* ctx.skill.transform((draft) => {
|
||||||
draft.source(
|
draft.source(
|
||||||
SkillV2.EmbeddedSource.make({
|
SkillV2.EmbeddedSource.make({
|
||||||
|
|
@ -58,7 +57,9 @@ export const Plugin = define({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
|
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
|
||||||
|
app: Context["app"],
|
||||||
|
) {
|
||||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||||
return [
|
return [
|
||||||
ReportContent,
|
ReportContent,
|
||||||
|
|
@ -67,8 +68,8 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||||
"",
|
"",
|
||||||
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
|
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
|
||||||
"",
|
"",
|
||||||
`- opencode version: ${InstallationVersion}`,
|
`- opencode version: ${app.version}`,
|
||||||
`- install/channel: ${InstallationChannel}`,
|
`- install/channel: ${app.channel}`,
|
||||||
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
||||||
`- Terminal: ${terminal()}`,
|
`- Terminal: ${terminal()}`,
|
||||||
`- Shell: ${shell()}`,
|
`- Shell: ${shell()}`,
|
||||||
|
|
|
||||||
101
packages/core/src/plugin/warming.ts
Normal file
101
packages/core/src/plugin/warming.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
export * as WarmingPlugin from "./warming"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import { Clock, Duration, Effect, Scope } from "effect"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { SessionSchema } from "../session/schema"
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
prompt: "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK",
|
||||||
|
interval: Duration.minutes(4),
|
||||||
|
duration: Duration.minutes(30),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.warming",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const loadSettings = Effect.fn("WarmingPlugin.loadSettings")(function* () {
|
||||||
|
const warming = Config.latest(yield* config.entries(), "warming")
|
||||||
|
if (!warming) return
|
||||||
|
const settings = warming === true ? defaults : { ...defaults, ...warming }
|
||||||
|
const interval = Duration.toMillis(settings.interval)
|
||||||
|
const duration = Duration.toMillis(settings.duration)
|
||||||
|
if (Number.isFinite(interval) && interval > 0 && Number.isFinite(duration) && duration > 0) return settings
|
||||||
|
yield* Effect.logWarning("warming interval and duration must be finite positive durations")
|
||||||
|
})
|
||||||
|
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
const sessions = new Map<SessionSchema.ID, { last: number; expires: number; settings: typeof defaults }>()
|
||||||
|
const loop: (sessionID: SessionSchema.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(
|
||||||
|
function* (sessionID) {
|
||||||
|
const current = sessions.get(sessionID)
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const next = Math.min(current.last + Duration.toMillis(current.settings.interval), current.expires)
|
||||||
|
if (now < next) {
|
||||||
|
yield* Effect.sleep(Duration.millis(next - now))
|
||||||
|
return yield* loop(sessionID)
|
||||||
|
}
|
||||||
|
if (now >= current.expires) {
|
||||||
|
sessions.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = current.last
|
||||||
|
yield* Effect.logInfo("warming session", { sessionID, last })
|
||||||
|
yield* ctx.session
|
||||||
|
.generate({ sessionID, prompt: current.settings.prompt })
|
||||||
|
.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to warm session", { sessionID, cause })))
|
||||||
|
const latest = sessions.get(sessionID)
|
||||||
|
if (latest === current && latest.last === last) latest.last = yield* Clock.currentTimeMillis
|
||||||
|
return yield* loop(sessionID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* ctx.session.hook("context", (event) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const active = sessions.get(event.sessionID)
|
||||||
|
const settings = yield* loadSettings()
|
||||||
|
if (!settings) {
|
||||||
|
sessions.delete(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once generate exposes request metadata to context hooks, tag warm requests instead of matching the prompt.
|
||||||
|
const message = event.messages.at(-1)
|
||||||
|
if (
|
||||||
|
message?.role === "user" &&
|
||||||
|
message.content.length === 1 &&
|
||||||
|
message.content[0]?.type === "text" &&
|
||||||
|
(message.content[0].text === active?.settings.prompt || message.content[0].text === settings.prompt)
|
||||||
|
) {
|
||||||
|
if (active) active.settings = settings
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const duration = Duration.toMillis(settings.duration)
|
||||||
|
if (active) {
|
||||||
|
active.last = now
|
||||||
|
active.expires = now + duration
|
||||||
|
active.settings = settings
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessions.set(event.sessionID, { last: now, expires: now + duration, settings })
|
||||||
|
yield* Effect.logInfo("scheduled session warming", {
|
||||||
|
sessionID: event.sessionID,
|
||||||
|
interval: settings.interval,
|
||||||
|
expires: now + duration,
|
||||||
|
})
|
||||||
|
yield* loop(event.sessionID).pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logError("session warming loop failed", { sessionID: event.sessionID, cause }),
|
||||||
|
),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -20,7 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { SessionV1 } from "./v1/session"
|
import { SessionV1 } from "./v1/session"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
import { App } from "./app"
|
||||||
import { Slug } from "./util/slug"
|
import { Slug } from "./util/slug"
|
||||||
import { ProjectTable } from "./project/sql"
|
import { ProjectTable } from "./project/sql"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -305,6 +305,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const app = yield* App.Metadata
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const db = database.db
|
const db = database.db
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|
@ -352,7 +353,7 @@ const layer = Layer.effect(
|
||||||
const info = SessionV1.SessionInfo.make({
|
const info = SessionV1.SessionInfo.make({
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: InstallationVersion,
|
version: app.version,
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
|
|
@ -1040,5 +1041,6 @@ export const node = makeGlobalNode({
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
Global.node,
|
Global.node,
|
||||||
|
App.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue