mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 15:06:16 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5352e107c0 | ||
|
|
dbd2e1ed79 | ||
|
|
7e1091e3eb | ||
|
|
759175abad | ||
|
|
88dd4417c7 | ||
|
|
4fb800734d | ||
|
|
473e4a960d | ||
|
|
2dfd08edc9 | ||
|
|
45e8c02bcc | ||
|
|
938baed23b | ||
|
|
494d70707b | ||
|
|
eab83d63fd | ||
|
|
fad352c5d0 | ||
|
|
e1b94a138e | ||
|
|
9e96aacd64 | ||
|
|
e19263ca87 | ||
|
|
63e33bc4dc | ||
|
|
999097bdac | ||
|
|
515b3022ac | ||
|
|
cb389aa679 | ||
|
|
30f4119459 | ||
|
|
4d17cf719f | ||
|
|
498b1deda3 | ||
|
|
25c54cbdf7 | ||
|
|
ae57dd0cf5 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +298,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +322,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -383,7 +383,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
@@ -411,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -456,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -493,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +508,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -554,7 +553,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -573,7 +572,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -586,7 +585,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -600,7 +599,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -615,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -654,7 +653,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -684,7 +683,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -699,7 +698,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -723,7 +722,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -744,7 +743,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -766,7 +765,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -801,7 +800,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -821,7 +820,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -855,7 +854,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -874,7 +873,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -920,7 +919,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -934,7 +933,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -968,7 +967,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1003,7 +1002,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1041,7 +1040,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1082,7 +1081,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3327,8 +3326,6 @@
|
||||
|
||||
"@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="],
|
||||
|
||||
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="],
|
||||
|
||||
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.7", "", { "os": "android", "cpu": "arm64" }, "sha512-t6Wx3Xll6c07Nuk0k3xnZsxKFxlshm92i0U/BiTHc6kQbvu+fMJF+gKsj4yEj886jH51CM3EqZT9Xdhq9CdUVw=="],
|
||||
@@ -3369,8 +3366,6 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
@@ -5723,8 +5718,6 @@
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
@@ -6393,12 +6386,6 @@
|
||||
|
||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||
|
||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w=="],
|
||||
|
||||
"ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -6703,12 +6690,6 @@
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C99M0T0SpRkcCClmJxbQkpSqGmxLfh3NhTsNF3aNaUQZZ7oXN5sPWi9LGs49X5Q/r9FWxBYeZARXs15xxtGIig=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"vite-plugin-dynamic-import/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -7257,8 +7238,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -7557,10 +7536,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
|
||||
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
@@ -8439,8 +8414,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@astrojs/cloudflare/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2RlbJRTEKSliuUbAE2lAktX63JFR/RKAuLkcCou8wb4=",
|
||||
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
|
||||
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
|
||||
"x86_64-linux": "sha256-4AqU8dPEwo0ukoyEX1SuDVzsXQ7G/fIPnxoL400zH4M=",
|
||||
"aarch64-linux": "sha256-p75sJYtR8a4oVKb1+Dgp8Kl+1onSmUw5NRv95bbV1+E=",
|
||||
"aarch64-darwin": "sha256-Psyvbw/lGulysQlhK/MqcHVIwx2aCDTr+Pfr+gRWsG4="
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -106,6 +106,41 @@ for await (const event of ai.llm.stream(ai.llm.request(input))) {
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
## Venice AI
|
||||
|
||||
`Venice` provides native Chat Completions with streaming tools and reasoning. `model` and `chat`
|
||||
select the same API; credentials default to `VENICE_API_KEY`.
|
||||
|
||||
```ts
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { Venice } from "@opencode/ai/providers"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generate({
|
||||
model: Venice.configure({ apiKey: process.env.VENICE_API_KEY }).chat("qwen3-6-27b"),
|
||||
prompt: "Explain this design.",
|
||||
providerOptions: {
|
||||
reasoningEffort: "high",
|
||||
veniceParameters: { includeVeniceSystemPrompt: false },
|
||||
},
|
||||
})
|
||||
console.log(response.text)
|
||||
})
|
||||
```
|
||||
|
||||
Effort lowers to `reasoning.effort`; `reasoning.enabled` and `reasoning.summary` are also available.
|
||||
Supported effort levels and toggles depend on the selected model. Omitted controls preserve its defaults.
|
||||
Venice's added system prompt is disabled by default, matching the previous OpenCode Venice SDK behavior.
|
||||
|
||||
Replay complete `response.message` values to retain signed/encrypted reasoning and Gemini thought
|
||||
signatures, including per-tool signatures. Venice's encrypted scalar trailers are excluded from visible
|
||||
reasoning but retained in provider metadata for replay. Cache affinity uses `promptCacheKey`, and cache-write
|
||||
usage reads Venice's `cache_creation_input_tokens` field.
|
||||
|
||||
The native package entrypoint is `@opencode/ai/providers/venice`. Image generation, embeddings, Responses, and
|
||||
client-side E2EE are not implemented by this provider.
|
||||
|
||||
## Experimental evaluation
|
||||
|
||||
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -584,10 +584,7 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerServerToolResult = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
providerMetadataKey: string,
|
||||
) {
|
||||
const lowerServerToolResult = Effect.fnUntraced(function* (part: ToolResultPart, providerMetadataKey: string) {
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||
@@ -657,10 +654,7 @@ const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocume
|
||||
|
||||
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value.trim())
|
||||
|
||||
const lowerMedia = Effect.fnUntraced(function* (
|
||||
part: MediaPart,
|
||||
breakpoints?: Cache.Breakpoints,
|
||||
) {
|
||||
const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, breakpoints?: Cache.Breakpoints) {
|
||||
const mime = part.media.mediaType.toLowerCase()
|
||||
const cacheControlValue = breakpoints ? cacheControl(breakpoints, part.cache) : undefined
|
||||
const fileId = fileIdFromMetadata(part.metadata)
|
||||
@@ -804,9 +798,6 @@ const requireThinkingSignature = (request: LLMRequest) => {
|
||||
return true
|
||||
}
|
||||
|
||||
// Mid-conversation system messages became available with Opus 4.8 and version
|
||||
// 5 of the other supported Claude families. Treat later family versions as
|
||||
// compatible without assuming that every Anthropic Messages model is Claude.
|
||||
// Opus 4.8 and every Claude 5 model accept mid-conversation system messages; later versions inherit support.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => {
|
||||
const version = claudeVersion(String(request.model.id))
|
||||
@@ -815,25 +806,9 @@ const supportsNativeSystemUpdates = (request: LLMRequest) => {
|
||||
return version.major >= 5
|
||||
}
|
||||
|
||||
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
const last = message.content.at(-1)
|
||||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
|
||||
}
|
||||
|
||||
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
|
||||
const previous = request.messages[index - 1]
|
||||
const next = request.messages[index + 1]
|
||||
// Vertex currently rejects/404s for a system message after local tool results,
|
||||
// so fold it into the user tool-result turn across continuations and history.
|
||||
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
|
||||
return (
|
||||
previous !== undefined &&
|
||||
previous.role !== "system" &&
|
||||
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
|
||||
next?.role !== "system" &&
|
||||
(next === undefined || next.role === "assistant")
|
||||
)
|
||||
}
|
||||
// Native system messages must follow a user turn (tool results count) or a paused server-tool turn.
|
||||
const acceptsNativeSystemAfter = (message: AnthropicMessage | undefined) =>
|
||||
message?.role === "user" || (message?.role === "assistant" && message.content.at(-1)?.type === "server_tool_use")
|
||||
|
||||
const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
|
||||
const pending = new Set<string>()
|
||||
@@ -862,12 +837,34 @@ const lowerNativeSystemUpdate = Effect.fnUntraced(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fnUntraced(function* (
|
||||
request: LLMRequest,
|
||||
const lowerWrappedSystemUpdate = Effect.fnUntraced(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
|
||||
return { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
|
||||
})
|
||||
|
||||
const appendToUserTurn = (messages: AnthropicMessage[], block: AnthropicUserBlock) => {
|
||||
const last = messages.at(-1)
|
||||
if (last?.role === "user") messages[messages.length - 1] = { role: "user", content: [...last.content, block] }
|
||||
else messages.push({ role: "user", content: [block] })
|
||||
}
|
||||
|
||||
const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoints: Cache.Breakpoints) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
// Text updates stay where they are unless a user turn follows them; then they move after the latest
|
||||
// user turn, the nearest spot where Anthropic accepts a native system message.
|
||||
const holdUpdates = supportsNativeSystemUpdates(request)
|
||||
const held: Array<LLMRequest["messages"][number]> = []
|
||||
const releaseHeld = Effect.fnUntraced(function* () {
|
||||
const native = acceptsNativeSystemAfter(messages.findLast((message) => message.role !== "system"))
|
||||
for (const update of held.splice(0)) {
|
||||
if (native) messages.push(yield* lowerNativeSystemUpdate(update, breakpoints))
|
||||
else appendToUserTurn(messages, yield* lowerWrappedSystemUpdate(update, breakpoints))
|
||||
}
|
||||
})
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
@@ -879,16 +876,8 @@ const lowerMessages = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (splitsLocalToolResults(request.messages, index))
|
||||
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
|
||||
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
||||
continue
|
||||
}
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
|
||||
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, block] }
|
||||
else messages.push({ role: "user", content: [block] })
|
||||
if (holdUpdates) held.push(message)
|
||||
else appendToUserTurn(messages, yield* lowerWrappedSystemUpdate(message, breakpoints))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -964,7 +953,9 @@ const lowerMessages = Effect.fnUntraced(function* (
|
||||
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
|
||||
)
|
||||
}
|
||||
if (content.length > 0) messages.push({ role: "assistant", content })
|
||||
if (content.length === 0) continue
|
||||
yield* releaseHeld()
|
||||
messages.push({ role: "assistant", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -985,6 +976,7 @@ const lowerMessages = Effect.fnUntraced(function* (
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
yield* releaseHeld()
|
||||
|
||||
return messages
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ const ExtraContent = Schema.Struct({
|
||||
})
|
||||
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
|
||||
|
||||
const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
export const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.tag("function"),
|
||||
function: Schema.Struct({
|
||||
@@ -134,7 +134,7 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
])
|
||||
type OpenAIChatUserContent = Schema.Schema.Type<typeof OpenAIChatUserContent>
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
export const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
@@ -200,7 +200,7 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
||||
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
|
||||
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
|
||||
// this provider-native event shape.
|
||||
const OpenAIChatUsage = Schema.StructWithRest(
|
||||
export const OpenAIChatUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
@@ -238,7 +238,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
|
||||
arguments: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
export const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||
@@ -246,7 +246,7 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
const OpenAIChatDelta = Schema.StructWithRest(
|
||||
export const OpenAIChatDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
refusal: optionalNull(Schema.String),
|
||||
@@ -259,7 +259,7 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
export const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
@@ -326,6 +326,8 @@ export interface ParserState {
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (cache: CacheHint | undefined) => OpenAIChatCacheControl | undefined
|
||||
readonly toolCallID?: (id: string) => string
|
||||
/** Project provider-specific fields from the exact source, even when other messages are dropped during lowering. */
|
||||
readonly assistant?: (source: LLMRequest["messages"][number], message: OpenAIChatMessage) => OpenAIChatMessage
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({
|
||||
@@ -539,8 +541,10 @@ const lowerMessage = Effect.fnUntraced(function* (
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant")
|
||||
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
|
||||
if (message.role === "assistant") {
|
||||
const lowered = yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)
|
||||
return [options.assistant?.(message, lowered) ?? lowered]
|
||||
}
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
})
|
||||
|
||||
@@ -1214,7 +1218,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
|
||||
export const finishEvents = Effect.fnUntraced(function* (state: ParserState) {
|
||||
if (state.finishReason === undefined && state.requireFinishReason)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { AIError, LLMEvent, type LanguageModelCompatibility, type LLMRequest } from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { OpenAIChat } from "./openai-chat.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { cacheControl } from "./utils/cache.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public options and request body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
|
||||
|
||||
const Parameters = Schema.Struct({
|
||||
enableWebSearch: Schema.optional(Schema.String),
|
||||
enableWebScraping: Schema.optional(Schema.Boolean),
|
||||
enableWebCitations: Schema.optional(Schema.Boolean),
|
||||
enableXSearch: Schema.optional(Schema.Boolean),
|
||||
stripThinkingResponse: Schema.optional(Schema.Boolean),
|
||||
disableThinking: Schema.optional(Schema.Boolean),
|
||||
includeVeniceSystemPrompt: Schema.optional(Schema.Boolean),
|
||||
characterSlug: Schema.optional(Schema.String),
|
||||
includeSearchResultsInStream: Schema.optional(Schema.Boolean),
|
||||
returnSearchResultsAsDocuments: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const Reasoning = Schema.Struct({
|
||||
effort: Schema.optional(Schema.String),
|
||||
enabled: Schema.optional(Schema.Boolean),
|
||||
summary: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type OptionsInput = {
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoning?: {
|
||||
readonly effort?: ReasoningEffort
|
||||
readonly enabled?: boolean
|
||||
readonly summary?: "auto" | "concise" | "detailed" | (string & {})
|
||||
}
|
||||
readonly veniceParameters?: Omit<typeof Parameters.Type, "enableWebSearch"> & {
|
||||
readonly enableWebSearch?: "off" | "on" | "auto" | (string & {})
|
||||
}
|
||||
readonly promptCacheKey?: string
|
||||
readonly promptCacheRetention?: "default" | "extended" | "24h" | (string & {})
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly maxCompletionTokens?: number
|
||||
readonly maxTokens?: number
|
||||
readonly minP?: number
|
||||
readonly repetitionPenalty?: number
|
||||
readonly stopTokenIds?: readonly number[]
|
||||
readonly logprobs?: boolean
|
||||
readonly topLogprobs?: number
|
||||
readonly maxTemp?: number
|
||||
readonly minTemp?: number
|
||||
readonly responseFormat?: Readonly<Record<string, unknown>>
|
||||
readonly user?: string
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Reasoning),
|
||||
veniceParameters: Schema.optional(Parameters),
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
promptCacheRetention: Schema.optional(Schema.String),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
maxCompletionTokens: Schema.optional(Schema.Number),
|
||||
maxTokens: Schema.optional(Schema.Number),
|
||||
minP: Schema.optional(Schema.Number),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
stopTokenIds: Schema.optional(Schema.Array(Schema.Number)),
|
||||
logprobs: Schema.optional(Schema.Boolean),
|
||||
topLogprobs: Schema.optional(Schema.Number),
|
||||
maxTemp: Schema.optional(Schema.Number),
|
||||
minTemp: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(JsonObject),
|
||||
user: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ToolCall = Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatAssistantToolCall.fields,
|
||||
thought_signature: Schema.optional(Schema.String),
|
||||
})
|
||||
const Assistant = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatMessage.cases.assistant.schema.fields,
|
||||
tool_calls: optionalArray(ToolCall),
|
||||
thought_signature: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Messages = Schema.Array(
|
||||
Schema.Union([
|
||||
OpenAIChat.OpenAIChatMessage.cases.system,
|
||||
OpenAIChat.OpenAIChatMessage.cases.user,
|
||||
Assistant,
|
||||
OpenAIChat.OpenAIChatMessage.cases.tool,
|
||||
]),
|
||||
)
|
||||
const Body = Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
messages: Messages,
|
||||
reasoning: Schema.optional(Reasoning),
|
||||
venice_parameters: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])),
|
||||
prompt_cache_retention: Options.fields.promptCacheRetention,
|
||||
parallel_tool_calls: Options.fields.parallelToolCalls,
|
||||
min_p: Options.fields.minP,
|
||||
top_k: Schema.optional(Schema.Number),
|
||||
repetition_penalty: Options.fields.repetitionPenalty,
|
||||
stop_token_ids: Options.fields.stopTokenIds,
|
||||
logprobs: Options.fields.logprobs,
|
||||
top_logprobs: Options.fields.topLogprobs,
|
||||
max_temp: Options.fields.maxTemp,
|
||||
min_temp: Options.fields.minTemp,
|
||||
response_format: Options.fields.responseFormat,
|
||||
user: Options.fields.user,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming schemas and state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ToolDelta = Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatToolCallDelta.fields,
|
||||
thought_signature: optionalNull(Schema.String),
|
||||
})
|
||||
const Delta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatDelta.schema.fields,
|
||||
tool_calls: optionalNull(Schema.Array(ToolDelta)),
|
||||
thought_signature: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Choice = Schema.StructWithRest(
|
||||
Schema.Struct({ ...OpenAIChat.OpenAIChatChoice.schema.fields, delta: optionalNull(Delta) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Usage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatUsage.schema.fields,
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
cache_write_tokens: optionalNull(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIChat.OpenAIChatEvent.schema.fields,
|
||||
choices: optionalNull(Schema.Array(Choice)),
|
||||
usage: optionalNull(Usage),
|
||||
error: optionalNull(Schema.Union([Schema.String, OpenAIChat.OpenAIChatEvent.schema.fields.error.schema])),
|
||||
issues: optionalArray(
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
path: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Number]))),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
interface State {
|
||||
readonly shared: OpenAIChat.ParserState
|
||||
readonly pending: string
|
||||
readonly reasoning: string
|
||||
readonly encrypted: boolean
|
||||
readonly signature?: string
|
||||
readonly tools: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const MARKER = "__ENCRYPTED_REASONING__"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request lowering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("VeniceChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
const body = yield* OpenAIChat.fromRequest(request, {
|
||||
cacheControl: cacheControl(),
|
||||
assistant: (source, message) => {
|
||||
if (message.role !== "assistant") return message
|
||||
const parts = source.content.filter(
|
||||
(part) => part.type === "text" || part.type === "reasoning" || part.type === "tool-call",
|
||||
)
|
||||
const calls = source.content.filter((part) => part.type === "tool-call")
|
||||
const signature = parts
|
||||
.map((part) => part.providerMetadata?.venice?.messageThoughtSignature)
|
||||
.find((value) => typeof value === "string")
|
||||
const raw = parts
|
||||
.map((part) => part.providerMetadata?.venice?.encryptedReasoningContent)
|
||||
.find((value) => typeof value === "string")
|
||||
return {
|
||||
...message,
|
||||
...(signature === undefined ? {} : { thought_signature: signature }),
|
||||
...(raw === undefined ? {} : { reasoning_content: raw }),
|
||||
tool_calls: message.tool_calls?.map((call, index) => {
|
||||
const signature = calls[index]?.providerMetadata?.venice?.thoughtSignature
|
||||
return { ...call, ...(typeof signature === "string" ? { thought_signature: signature } : {}) }
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
return {
|
||||
...body,
|
||||
max_completion_tokens: options.maxCompletionTokens ?? options.maxTokens ?? request.generation?.maxTokens,
|
||||
reasoning_effort: undefined,
|
||||
reasoning:
|
||||
options.reasoningEffort === undefined
|
||||
? options.reasoning
|
||||
: { ...options.reasoning, effort: options.reasoningEffort },
|
||||
// Match the previous Venice SDK default, not the gateway's added prompt.
|
||||
venice_parameters: Object.fromEntries(
|
||||
Object.entries({
|
||||
...options.veniceParameters,
|
||||
includeVeniceSystemPrompt: options.veniceParameters?.includeVeniceSystemPrompt ?? false,
|
||||
})
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => [key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), value]),
|
||||
),
|
||||
prompt_cache_retention: options.promptCacheRetention,
|
||||
parallel_tool_calls: options.parallelToolCalls,
|
||||
min_p: options.minP,
|
||||
top_k: request.generation?.topK,
|
||||
repetition_penalty: options.repetitionPenalty,
|
||||
stop_token_ids: options.stopTokenIds,
|
||||
logprobs: options.logprobs,
|
||||
top_logprobs: options.topLogprobs,
|
||||
max_temp: options.maxTemp,
|
||||
min_temp: options.minTemp,
|
||||
response_format: options.responseFormat,
|
||||
user: options.user,
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Venice normalization around the shared Chat state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const step = Effect.fn("VeniceChat.step")(function* (state: State, event: typeof Event.Type) {
|
||||
if (typeof event.error === "string")
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: [
|
||||
event.error,
|
||||
...(event.issues ?? []).map(
|
||||
(issue) => `${issue.path?.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`,
|
||||
),
|
||||
].join("; "),
|
||||
rawBody: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
const delta = event.choices?.[0]?.delta
|
||||
const scalar = delta?.reasoning_content ?? ""
|
||||
const text = state.pending + scalar
|
||||
const marker = text.indexOf(MARKER)
|
||||
const pending =
|
||||
marker >= 0 || state.encrypted
|
||||
? 0
|
||||
: (Array.from({ length: Math.min(text.length, MARKER.length - 1) }, (_, index) => index + 1)
|
||||
.filter((length) => text.endsWith(MARKER.slice(0, length)))
|
||||
.at(-1) ?? 0)
|
||||
const visible = state.encrypted ? "" : marker >= 0 ? text.slice(0, marker) : text.slice(0, text.length - pending)
|
||||
const result = yield* OpenAIChat.protocol.stream.step(state.shared, {
|
||||
...event,
|
||||
error: event.error,
|
||||
usage: event.usage
|
||||
? {
|
||||
...event.usage,
|
||||
prompt_tokens_details: event.usage.prompt_tokens_details
|
||||
? {
|
||||
...event.usage.prompt_tokens_details,
|
||||
cache_write_tokens:
|
||||
event.usage.prompt_tokens_details.cache_write_tokens ??
|
||||
event.usage.prompt_tokens_details.cache_creation_input_tokens,
|
||||
}
|
||||
: event.usage.prompt_tokens_details,
|
||||
}
|
||||
: event.usage,
|
||||
choices: event.choices?.map((choice, index) =>
|
||||
index === 0 && choice.delta
|
||||
? {
|
||||
...choice,
|
||||
delta: {
|
||||
...choice.delta,
|
||||
reasoning_content: visible || undefined,
|
||||
// Opaque-only scalar reasoning still needs a canonical part for replay.
|
||||
reasoning_details: choice.delta.reasoning_details ?? (marker >= 0 ? [] : undefined),
|
||||
},
|
||||
}
|
||||
: choice,
|
||||
),
|
||||
})
|
||||
const tools = { ...state.tools }
|
||||
for (const call of delta?.tool_calls ?? []) {
|
||||
if (!call.thought_signature) continue
|
||||
const index = call.index ?? result[0].latestToolIndex
|
||||
const id =
|
||||
call.id ?? (index === undefined ? undefined : (result[0].tools[index]?.id ?? result[0].pendingTools[index]?.id))
|
||||
if (id) tools[id] = call.thought_signature
|
||||
}
|
||||
return [
|
||||
{
|
||||
shared: result[0],
|
||||
pending: pending ? text.slice(-pending) : "",
|
||||
reasoning: state.reasoning + scalar,
|
||||
encrypted: state.encrypted || marker >= 0,
|
||||
signature: delta?.thought_signature ?? state.signature,
|
||||
tools,
|
||||
},
|
||||
result[1],
|
||||
] as const
|
||||
})
|
||||
|
||||
const onHalt = Effect.fn("VeniceChat.onHalt")(function* (state: State) {
|
||||
const events = yield* OpenAIChat.finishEvents(state.shared)
|
||||
return events.flatMap((event): LLMEvent[] => {
|
||||
if (
|
||||
event.type !== "reasoning-end" &&
|
||||
event.type !== "text-end" &&
|
||||
event.type !== "tool-call" &&
|
||||
event.type !== "tool-input-end"
|
||||
)
|
||||
return [event]
|
||||
const signature = event.type === "tool-call" || event.type === "tool-input-end" ? state.tools[event.id] : undefined
|
||||
const providerMetadata = {
|
||||
...event.providerMetadata,
|
||||
venice: {
|
||||
...event.providerMetadata?.venice,
|
||||
...(state.signature ? { messageThoughtSignature: state.signature } : {}),
|
||||
...(signature ? { thoughtSignature: signature } : {}),
|
||||
...(event.type === "reasoning-end" && state.encrypted ? { encryptedReasoningContent: state.reasoning } : {}),
|
||||
},
|
||||
}
|
||||
if (event.type === "reasoning-end" && state.pending)
|
||||
return [LLMEvent.reasoningDelta({ id: event.id, text: state.pending }), { ...event, providerMetadata }]
|
||||
return [{ ...event, providerMetadata }]
|
||||
})
|
||||
})
|
||||
|
||||
export const compatibility = {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
supportsStore: false,
|
||||
supportsPromptCacheKey: true,
|
||||
} satisfies LanguageModelCompatibility
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "venice-chat",
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
event: Schema.Union([Schema.Literal("[DONE]"), Protocol.jsonEvent(Event)]),
|
||||
initial: (request): State => ({
|
||||
shared: OpenAIChat.protocol.stream.initial(request),
|
||||
pending: "",
|
||||
reasoning: "",
|
||||
encrypted: false,
|
||||
tools: {},
|
||||
}),
|
||||
step: (state: State, event) => (event === "[DONE]" ? Effect.succeed([state, []] as const) : step(state, event)),
|
||||
terminal: (event) => event === "[DONE]",
|
||||
onHalt,
|
||||
},
|
||||
})
|
||||
|
||||
export * as VeniceChat from "./venice-chat.js"
|
||||
@@ -54,6 +54,7 @@ const route = Route.make({
|
||||
),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
|
||||
@@ -40,6 +40,7 @@ export * as Stability from "./stability.js"
|
||||
export * as TogetherAI from "./togetherai.js"
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
export * as VercelAIGateway from "./vercel-ai-gateway.js"
|
||||
export * as Venice from "./venice.js"
|
||||
export * as XAI from "./xai.js"
|
||||
export * as ZAI from "./zai.js"
|
||||
export * as ZAICodingPlan from "./zai-coding-plan.js"
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { VeniceChat } from "../protocols/venice-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("venice")
|
||||
export type ChatOptionsInput = VeniceChat.OptionsInput
|
||||
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: ChatOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ChatOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "venice-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "venice",
|
||||
protocol: VeniceChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.venice.ai/api/v1" }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, queryParams, ...rest } = input
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
route
|
||||
.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? route.endpoint.baseURL, query: queryParams },
|
||||
auth: AuthOptions.bearer(input, "VENICE_API_KEY"),
|
||||
})
|
||||
.model<ChatOptionsInput>({ id: modelID, compatibility: VeniceChat.compatibility })
|
||||
return { id, model: chat, chat, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const chat = provider.chat
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, queryParams, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey,
|
||||
baseURL,
|
||||
queryParams,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Venice from "./venice.js"
|
||||
@@ -171,6 +171,33 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases a held system update next to an effort marker as one valid section", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus5,
|
||||
messages: [
|
||||
Message.user("Fix it."),
|
||||
Message.assistant("Done."),
|
||||
lowFromHigh,
|
||||
Message.system("Update."),
|
||||
Message.user("Next."),
|
||||
],
|
||||
providerOptions: { effort: "low" },
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Fix it." }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Done." }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "text", text: "Next." }] },
|
||||
{ role: "system", content: [{ type: "text", text: "Update.", cache_control: undefined }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const drifted = yield* compileRequest(
|
||||
@@ -266,20 +293,28 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
|
||||
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
|
||||
const opus5 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
|
||||
"claude-opus-5",
|
||||
),
|
||||
model: vertex.model("claude-opus-5"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
const opus48 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertex.model("claude-opus-4-8"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(prepared.body)).toHaveLength(0)
|
||||
expect(prepared.body.output_config).toEqual({ effort: "low" })
|
||||
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
|
||||
expect(opus5.body.output_config).toEqual({ effort: "high" })
|
||||
expect(systemMessages(opus48.body)).toHaveLength(0)
|
||||
expect(opus48.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "claude-opus-4-8",
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation",
|
||||
"usage",
|
||||
"signed"
|
||||
],
|
||||
"name": "venice-chat/claude-opus-4-8-preserves-reasoning-through-a-tool-loop-and-follow-up",
|
||||
"recordedAt": "2026-10-05T03:00:37.127Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169233,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"I need to find the rate for\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"I need to find the rate for\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" ZEBRA.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\" ZEBRA.\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\n\\n\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\\n\\n\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\",\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\"}],\"tool_calls\":[{\"index\":0,\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"\"}}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"code\\\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\": \\\"ZEBR\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"A\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"msg_011CfiLtM3Rj4dVUWFJAEjoy\",\"object\":\"chat.completion.chunk\",\"created\":1791169234,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":499,\"completion_tokens\":74,\"total_tokens\":573,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.005214,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to find the rate for ZEBRA.\\n\\n\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\"}],\"reasoning_content\":\"I need to find the rate for ZEBRA.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169235,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"23 times 17 is \",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"23 times 17 is \"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"391, plus 9 g\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"391, plus 9 g\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"ives 400.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"ives 400.\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\n\\n\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\\n\\n\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"text\":\"\",\"signature\":\"ErgCCrwBCBIYAipAzm6Uz1fiUimJZ7ry16/5jOPhPfQ9Zm3zSb449atO4+Dv3KroxOwD17dLjPUhdRWeFNghT/lQsCRlsY9T6SWyMzIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEGhpfnmythMRLK5wcAMHasmIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB1KWM1gawAQISDA4K+cR55K4LQVXyhhoMBUHsSBuaNQlHp5ZPIjBvSX58DhVBxFRl9sVZYgZwrMDqGzC1/tgyH1wflAP4TkHTPje+Gdbv0yUxBBIQtaEqKSS4Bh4olHSAKxLEG1SyH4Iy46lp50SxdXxGJ/PIwTfHI47SEaE9pEfNGAE=\"}],\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"400\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"msg_011CfiLtUi3ffKFSXdhWtFbp\",\"object\":\"chat.completion.chunk\",\"created\":1791169236,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":183,\"completion_tokens\":23,\"total_tokens\":206,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.001788,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-opus-4-8\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"I need to find the rate for ZEBRA.\\n\\n\",\"id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"EsECCrwBCBIYAipASM082xA/ckQU1q1MLSuAGRD/F94kvqoBlyXoEKdxvH2f2kn+ESR0AZfmYtv8VB8ux7LQ+qq4DnODYQ2iBHemajIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEOtICOPsjcfgmcoVEjPetDWIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB0qWM1gawAQISDFJE1AThUPJ/0OxIBxoMak46jCiQRsOzj1nIIjBV1UQ4WoUBtZX8F6kyFtKyRPkLdWp7WQKQYd+f4VT3GvmEqenUY2ANNJImtDj/BQAqMhz7JVFNqrlemYzJNRbF6ntHDPc2enCrd/tYXBG6nms1qbcfKWc7CYc2ZGogOqiItKNDGAE=\"}],\"reasoning_content\":\"I need to find the rate for ZEBRA.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"toolu_01KYDseHbTDZXSYg9bNwiwxU\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"23 times 17 is 391, plus 9 gives 400.\\n\\n\",\"format\":\"anthropic-claude-v1\",\"index\":0,\"signature\":\"ErgCCrwBCBIYAipAzm6Uz1fiUimJZ7ry16/5jOPhPfQ9Zm3zSb449atO4+Dv3KroxOwD17dLjPUhdRWeFNghT/lQsCRlsY9T6SWyMzIPY2xhdWRlLW9wdXMtNC04OABCCHRoaW5raW5nWiQ1YzJjNzI5YS0wMzVjLTQ5ZDYtODNiOC03NGNhZTAyMzU2NWRyEGhpfnmythMRLK5wcAMHasmIAQGaAREKD2NsYXVkZS1vcHVzLTQtOKgB1KWM1gawAQISDA4K+cR55K4LQVXyhhoMBUHsSBuaNQlHp5ZPIjBvSX58DhVBxFRl9sVZYgZwrMDqGzC1/tgyH1wflAP4TkHTPje+Gdbv0yUxBBIQtaEqKSS4Bh4olHSAKxLEG1SyH4Iy46lp50SxdXxGJ/PIwTfHI47SEaE9pEfNGAE=\"}],\"reasoning_content\":\"23 times 17 is 391, plus 9 gives 400.\\n\\n\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n: processing\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"407\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"msg_011CfiLtbruiyeuxJmswkPPt\",\"object\":\"chat.completion.chunk\",\"created\":1791169237,\"model\":\"claude-opus-4-8\",\"choices\":[],\"usage\":{\"prompt_tokens\":229,\"completion_tokens\":3,\"total_tokens\":232,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.001464,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "deepseek-v4-flash",
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation",
|
||||
"usage",
|
||||
"scalar"
|
||||
],
|
||||
"name": "venice-chat/deepseek-v4-flash-preserves-reasoning-through-a-tool-loop-and-follow-up",
|
||||
"recordedAt": "2026-10-05T02:59:53.799Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"Th\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e user wants me to look\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" up the conversi\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"on rate fo\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"r code ZEBRA\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\", then mult\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"iply by\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 17 and add 9. Let m\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e start by lo\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"oking up the rate.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"function\":{\"arguments\":\"\",\"name\":\"lookup_rate\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-Rhr7pZI7Hv6zIafL3B1KoyzY\",\"object\":\"chat.completion.chunk\",\"created\":1791169188,\Line truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_content\":\"The user wants me to look up the conversion rate for code ZEBRA, then multiply by 17 and add 9. Let me start by looking up the rate.\"},{\"role\":\"tool\",\"tool_call_id\":\"chatcmpl-tool-ab656a8b683341f2\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"We have the\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" rate: 2\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"3. Mul\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"tiply by\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 17: 2\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"3 * 17\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" = 391.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" Then ad\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"d 9:\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 391 + 9 = 400.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"400\",\"reasoning_content\":\" So the answer is 400.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RtwpMZBraGwqJiKRKQFrmmIq\",\"object\":\"chat.completion.chunk\",\"created\":1791169191,\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":128,\"completion_tokens\":46,\"total_tokens\":174,\"prompt_tokens_details\":{\"cached_tokens\":0}}Line truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"chatcmpl-tool-ab656a8b683341f2\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_content\":\"The user wants me to look up the conversion rate for code ZEBRA, then multiply by 17 and add 9. Let me start by looking up the rate.\"},{\"role\":\"tool\",\"tool_call_id\":\"chatcmpl-tool-ab656a8b683341f2\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_content\":\"We have the rate: 23. Multiply by 17: 23 * 17 = 391. Then add 9: 391 + 9 = 400. So the answer is 400.\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"We need to add\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" 7 to t\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"h\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"e previous ans\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"wer,\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" which\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\" was 400. S\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":\"o 400+7=407. Reply wi\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"407\",\"reasoning_content\":\"th just the number.\",\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-R0MMWxoRA5NidBHTttxrMoVe\",\"object\":\"chat.completion.chunk\",\"created\":1791169192,\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":148,\"completion_tokens\":33,\"total_tokens\":181,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"cost\":{\"usd\":0.000029499,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "gemini-3-1-pro-preview",
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation",
|
||||
"usage",
|
||||
"details"
|
||||
],
|
||||
"name": "venice-chat/gemini-3-1-pro-preview-preserves-reasoning-through-a-tool-loop-and-follow-up",
|
||||
"recordedAt": "2026-10-05T03:00:58.104Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"format\":\"google-gemini-v1\",\"id\":\"call_4908320\",\"index\":0}],\"reasoning_encrypted\":true},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169245-MjiWKnEhpgHVKzFcOlS0\",\"object\":\"chat.completion.chunk\",\"created\":1791169245,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":96,\"completion_tokens\":169,\"total_tokens\":265,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":152,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.002775,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"id\":\"call_4908320\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_4908320\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"400\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"AY89a1+fQ9YpZF71EoH4pNDO6g5NeqvLRAav/WG6sv9V/5fpifhK6ZDc6m89qgCpOEBhjTJ+a+ux1dFog+eB2nR/G8VVKt1Zr2+2NKXO3Q3ksKE+E51IRRzK/ywpDYpE8AlE6Kb7EG2x/ZVMpy24TOLY+LL8JNsrz8uHBC1PtPtiZe/gP+Kvu0Yqk+VNYv16wAubtiA0oy0c+wyRzd80jRvqCZpBIGPlLAU515MPpjXNGu0A5ft4uu3oGPJHl5G/Ry4Fm5fvWKl759+Jwx9m7d50+vcttr5SpHm//MvNsUTnqjYkraw1S69+9oB57HoBn8d8pwTHpud/6FARun33EjfPW86Xx2fS448QtKc3c9nxSdGyZgTV0TpA63Uz7n0nGd9fRYrghp3lQv5QyoRIgNg+YeBf/WDNzSW2MO6Y2HLFoo3K0vJh1KMfWhjECNxP1Qfjfz+VjwSV7+OzF9CNw23f8R2udRBr1EUYhTu13ckjFLWjCD5fGjvmLhsae3CgmuFV2Rvaxqrf/H79Tz+X9W/Tw8PuQ0E1pCCpiMe1gR1CT+LJPWxjV96DPHQ0r+1lnekrr5NkO7UAZ64HVALHJRpACdJsVhwI1TBi4f89b1by1s7OaWlQTw7thX7CLRV2W3HoMUccJaofszVEfbD8+SaeZtZgyw9mkYvxgYtu3Odxx2YGHgs=\",\"format\":\"google-gemini-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169249-6wlEwbJv7MicDxVsZViY\",\"object\":\"chat.completion.chunk\",\"created\":1791169249,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":233,\"completion_tokens\":129,\"total_tokens\":362,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":126,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.0025175,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-1-pro-preview\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4908320\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19Tkd2zuyuB3m6as2cMeOoFk8xFfcBdsUI+RBkTCE1GVBd5hMIfNsjOj+YTucPL1hYoWodr2oZsMKDnPuYee0AmGYl8jHzVKgQFSXzgIeWEhG+hOMvTEa4BoruFowcHzbLhym48OlBys47Tlo7WF4tK4s8Z7q8cpbqGsA0NkUqPt8P7Uk3ZaRTUJBbIE08+JgNUUwiJkT4AVSO635aMUYTcIkt+8TVbqOLITfSdDtkxhAmrInfn7FqGlNop3RH1TUcb3xnUOsnHXoreWLlAyWTlWNvOa7hjUH8vUwILPmtG6ixKZdf4i/UIgm551Z5tgE05SJBcR/m7ALagyOyEilWqxGkyf+5jvNycUlk/mBkNHLV45amgUG5zhboVEAEVFYPRa+Nnfzxi+DEdM1ntDURrB5dl4Jbotx67627EbsZm4z2sKUZ4jo/eBj9fI3MUqjTYKtMXBSyuen/m/Uw2Ji9iIQIlWUt/G86kOjQUYFY60g6TxEO4BLouwLZJ0fXxWdolvtMnH90+skJ+I7aFQjGrnwdjGsT0oXA0/vG3W8GhRSokdICVQBq14EFvjcVvI1kEgj7NTtKkB/+UWRtRbRhSA5F25/dHvqAT4ioYy/5sRwYFXMwJmM52I8DpLiaBrp8wTidLNjTpD9KlImbd2r89LS9GG5NeqNWksF9XbEQAMannsu19HC9xf+CCz5FBz5PjpR0+pvybIJLmph94u4I0cdLsbycbbdIaihqeIc593sQv3nvm9VYZMJcgnQWIKmXofSlV4zi6p0wRPvmgeiPo+Lv6DrtPTtcaRLkkyGrg8uvkq2Is9H6me8y8HCXGIze3np22MjWM5r2+UYMia+xY+E3neBC1AZrlN+GJarvf0THgHInLLnE78f+w3mJByFgXMy6OOEs4QI0+F8yxF9GembhqebXYKWGWUkuArnLfV6TZ13v8s1J9IiNObPU0AJjq+wmlg2ChAIs082sh8Q==\",\"id\":\"call_4908320\",\"format\":\"google-gemini-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating ZEBRA Conversion**\\n\\nI'm currently processing the calculation involving ZEBRA's conversion rate. My next step is to use the `lookup_rate` tool with the code \\\"ZEBRA\\\" to fetch the necessary value, which will then be used in the full equation.\\n\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_4908320\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"format\":\"google-gemini-v1\",\"index\":0,\"signature\":\"AY89a1+fQ9YpZF71EoH4pNDO6g5NeqvLRAav/WG6sv9V/5fpifhK6ZDc6m89qgCpOEBhjTJ+a+ux1dFog+eB2nR/G8VVKt1Zr2+2NKXO3Q3ksKE+E51IRRzK/ywpDYpE8AlE6Kb7EG2x/ZVMpy24TOLY+LL8JNsrz8uHBC1PtPtiZe/gP+Kvu0Yqk+VNYv16wAubtiA0oy0c+wyRzd80jRvqCZpBIGPlLAU515MPpjXNGu0A5ft4uu3oGPJHl5G/Ry4Fm5fvWKl759+Jwx9m7d50+vcttr5SpHm//MvNsUTnqjYkraw1S69+9oB57HoBn8d8pwTHpud/6FARun33EjfPW86Xx2fS448QtKc3c9nxSdGyZgTV0TpA63Uz7n0nGd9fRYrghp3lQv5QyoRIgNg+YeBf/WDNzSW2MO6Y2HLFoo3K0vJh1KMfWhjECNxP1Qfjfz+VjwSV7+OzF9CNw23f8R2udRBr1EUYhTu13ckjFLWjCD5fGjvmLhsae3CgmuFV2Rvaxqrf/H79Tz+X9W/Tw8PuQ0E1pCCpiMe1gR1CT+LJPWxjV96DPHQ0r+1lnekrr5NkO7UAZ64HVALHJRpACdJsVhwI1TBi4f89b1by1s7OaWlQTw7thX7CLRV2W3HoMUccJaofszVEfbD8+SaeZtZgyw9mkYvxgYtu3Odxx2YGHgs=\"}]},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"407\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"AY89a1/t2vneT6Dy8hJmYd66cSC0Fk3bKHK7hJI5vTtBcKe38EQ6JXMwTlxi2K47pfmvruV0B6Ks0CZCF9N7rEOT+fXTleU1WPEAHHJPX7KkncEJK4KNIeJsTzyEjSPn5t8kb/vNruIV2KE2/l0kylycTNOubuO8DGlkKOp478zuwZppB0vFaoLp1byEUtyT/MHdN/KckvvKY99ZB2ksWrc51/5Z7jx7GeXGpTl3jHEKzdLWrfsYYyjPOv4h0ZHN8ANmYMTWpGUm8jqxB0pHQFBVS0U5YhF+fuU6Nk3lUNuLnEydq6UH6VydtTxDjUDALIfHhkasmTRXnL/7X3/z3rLhRXRGhga9lHnSnEhmjdLj9iOn/73wjA4QxnT14P+8F45z4ePO19ZGKr/j5WBvBMem92Yqv9HweDo9IreBa8EbqPwlsnjbXvJB7MuBp2TJd4szb7azQ+zaPU7pf5B3fjKC3K8p26qF0C+TkGd72PezZw662/FMkkC/b88sgDgMfgSK7dMqVvv7swafRzFL1IZzTFoJJ3Ouk/VCKGWCHOx+c2EPtvv+IzGhQ1k9dn44bq953wBamkzDjQWcP46+VKp2PPz5yV9AgGCmdi1SRYNU361FiUGlU40cImBLK1MoIqa/YnYaaXMtK9L7/35uOjyg/hae3REIw5N4Y1fiSx9UCKi7eYviBwrY3kZJYdy4UdsN63ahHb2SO1gfZKAg1G36Gn5TIiYGO4dNWE9c\",\"format\":\"google-gemini-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"service_tier\":\"provisioned\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"STOP\"}]}\n\ndata: {\"id\":\"gen-1791169254-bvEsScICXWimu8xzJFF1\",\"object\":\"chat.completion.chunk\",\"created\":1791169254,\"model\":\"gemini-3-1-pro-preview\",\"choices\":[],\"usage\":{\"prompt_tokens\":99,\"completion_tokens\":119,\"total_tokens\":218,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":116,\"image_tokens\":0,\"audio_tokens\":0}},\"cost\":{\"usd\":0.0020325,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"tool",
|
||||
"parallel",
|
||||
"signature"
|
||||
],
|
||||
"name": "venice-chat/gemini-preserves-per-call-signatures-through-parallel-tools",
|
||||
"recordedAt": "2026-10-05T03:21:54.383Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}]}}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]}}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gcp-1791170510482-itouwq\",\"object\":\"chat.completion.chunk\",\"created\":1791170510,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":74,\"completion_tokens\":156,\"total_tokens\":230},\"cost\":{\"usd\":0.000800625,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"},\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\"},{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_0\",\"content\":\"{\\\"rate\\\":23}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"content\":\"{\\\"rate\\\":31}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"The retrieved rates are:\\n- **\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ZEBRA:** 23\\n- **MOOSE:** 31\\n\\n### Calculation:\\n1. **Add\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the rates:** \\n $$23 + 31 = 54$$\\n\\n2. **Multiply by 7:** \"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\n $$54 \\\\times 7 = 378$$\\n\\nThe final result is **378\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"**.\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"thought_signature\":\"AY89a18wXUWI7m5/5GGqxfoJDqE4MU2X2SR/ZQMCX+JQ/clHQynyEjplK4a8w9tXAi8uuxYPVxYj1ULcb83zUQBsr77tSkJN5R0J2Jgc2HzYtmTW7OYHoFQqmMESnDjvDiO51y3xpXGbVkqRlXwfmap+Tpivfx45hiXlns/hObEQo1gE70NzD7d8CQOseYVz2N7MPMmGDqpAy+5SAHXGAcQ9K2ufHNUdfWM/uUi+sGUcDhGnjPXPlzIuYk7IjUQXNAH9FFqo19dBQPhE106C3aI69QAuMvyZZ/no4ou9XSOUXrstjzQwUM+TOJXsab80F1+C6DghvYm0UZSb827xCKPtj7oCQwtwg+f4sWELhflp42IL3URo2UxpZaDNpt7PYXmmcCvKAN7/PTirCijNWMtlDL0LDbiKj+DfOFEuS2VxVq+yQgWRbKJZF7bl8BCSe7+Qab0bAsu0ZJXKBlxs46ulaXn5cc933y/Yv7O0XGHbVULMzeEpZndzkHqZYs5D+ve+CJFfjWsVk1NKrTdXqt11Ov470+MCpSlHTXdjug7oUd0A0b5AH+bF4yAZAGj4snJ7hi04EQ4Ch7rASB3OB+YB+8SnStAGf0XuimNgY2wzR68qaQ==\"}}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gcp-1791170512529-w6lmsz\",\"object\":\"chat.completion.chunk\",\"created\":1791170512,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":206,\"completion_tokens\":208,\"total_tokens\":414},\"cost\":{\"usd\":0.001168125,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3-8-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"},\"thought_signature\":\"AY89a1/bp+ihzOXpD71E6UhQv6xnBjHTAdPeNHvLEAevtqznKg46D/3qO3e9Qg7wGmccL6IpLV56PwWIT+6pXoeU6ix8JCCk7nlml/Sz2huX/rNFoSRHpB+YAZK07lHP9cbotXy7FPNMaRhsl18sPSJZLlPK0iTGhJa7MmH72r4v/G3/izffMKUPRos6i5qQ52La10FUWSunPigqNnglEvWEC3Yb+d+PnEq5iOPaVCD0awXjhsIk8L0n4+T6jM4epO+whUSSgvH9PGwgmsP197hb7l5YSjQncNX0oF1G4ch3xrFzXyS/noFFy6SlBfzfN0NJmAyP//4oVqjffUJngigTAtboI4HdDwH1fPHuI5FTfmVcUGVVIEjhAEDAAHtS77uW/XdLH4bsSigjYeCzgRTYmHP8bqFb1HjRjl2HK2Pr1y6ch0JSIgtAKv8Kj4R0PM8rIYxxf7LgrsaVWogt4rq2M4HvcUcBi6IhQ/500JUCoqdhlTGMqCFUHkN6P/qAoPGZXmo50OvtNChQ4vXVGYxWp89IRwdcv8vqjIFk75/R6oZG9BsrUaC1JnQN8i3BbYc1bz/lzFCQ1IC/g1Sm+wPhsaPmoVN77101ZAdNMiP4B5Ow7K/UycMunFKWy4xdpaT7Pb+LLNy6GYy9q2TvoRXTq1lJugLH4Q8TQZj5neWZmiDxqzsj0Xvwew7zLwGSMfkpBB4rgx9XWMx5CwqO068qjhmo+0gUkxMug2RHblEGnPO7i0i9fYj9ltDfUpFxN2adDpyk4dwuIeVSlu4N4oHOgjGms5jWLp0MqkyoKFo=\"},{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"MOOSE\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_0\",\"content\":\"{\\\"rate\\\":23}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"content\":\"{\\\"rate\\\":31}\"},{\"role\":\"assistant\",\"content\":\"The retrieved rates are:\\n- **ZEBRA:** 23\\n- **MOOSE:** 31\\n\\n### Calculation:\\n1. **Add the rates:** \\n $$23 + 31 = 54$$\\n\\n2. **Multiply by 7:** \\n $$54 \\\\times 7 = 378$$\\n\\nThe final result is **378**.\",\"thought_signature\":\"AY89a18wXUWI7m5/5GGqxfoJDqE4MU2X2SR/ZQMCX+JQ/clHQynyEjplK4a8w9tXAi8uuxYPVxYj1ULcb83zUQBsr77tSkJN5R0J2Jgc2HzYtmTW7OYHoFQqmMESnDjvDiO51y3xpXGbVkqRlXwfmap+Tpivfx45hiXlns/hObEQo1gE70NzD7d8CQOseYVz2N7MPMmGDqpAy+5SAHXGAcQ9K2ufHNUdfWM/uUi+sGUcDhGnjPXPlzIuYk7IjUQXNAH9FFqo19dBQPhE106C3aI69QAuMvyZZ/no4ou9XSOUXrstjzQwUM+TOJXsab80F1+C6DghvYm0UZSb827xCKPtj7oCQwtwg+f4sWELhflp42IL3URo2UxpZaDNpt7PYXmmcCvKAN7/PTirCijNWMtlDL0LDbiKj+DfOFEuS2VxVq+yQgWRbKJZF7bl8BCSe7+Qab0bAsu0ZJXKBlxs46ulaXn5cc933y/Yv7O0XGHbVULMzeEpZndzkHqZYs5D+ve+CJFfjWsVk1NKrTdXqt11Ov470+MCpSlHTXdjug7oUd0A0b5AH+bF4yAZAGj4snJ7hi04EQ4Ch7rASB3OB+YB+8SnStAGf0XuimNgY2wzR68qaQ==\"},{\"role\":\"user\",\"content\":\"Add 7 to your previous final answer. Reply with just the number.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"effort\":\"high\"},\"venice_parameters\":{\"include_venice_system_prompt\":false},\"parallel_tool_calls\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"385\"}}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{\"thought_signature\":\"AY89a1/DMIB/2qixC5QVQQH1tA2QiQFpEsXnIDd7wlCgwuYQXBGWgSsP1bpTZJUJM/yqKdQh96+/c6c28VMbW5iovCDxxeK56KfmSBSBIPtNW8BUHHLxQw9vdsxQvQyHcGW6rLb4W3gElqx6JFmNkTMvijUS9QDgkPlPipirbv7pqLejFyQ040GD4IVhP8ovyR49EJm61W0JvJOPFT30/gVvaKA6RnRgTKGiHrZWVqLxhKVkb0gC7jI9CNNASOYH67uelFQ2trbcf5Qbhx4q93vAErFsBzvzliSuaELDrxZAF9VCNIiWz4AUtEWI1FLCMYxkgNry/K+iNpwcXu8BxABETbNkEb722wjpt1A2EZK/5qhqKQvUOq+WgSCPCLoI1zfUiGN6UV9D/QGtyS2Iyt3AYlvKjZVVzP217ItWSDacGzigLd4t+4MMW7Dvbs7ffBvBV+EzMCP1dnsLkKCxicfvT3e2EgY+liDSaSZG3sE7Lm4WKCYbI4rrBLMupERSaFfAHWdTlea5HeM=\"}}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gcp-1791170514341-j5gh1h\",\"object\":\"chat.completion.chunk\",\"created\":1791170514,\"model\":\"gemini-3-8-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":429,\"completion_tokens\":83,\"total_tokens\":512},\"cost\":{\"usd\":0.00079125,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai-gpt-54-mini",
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation",
|
||||
"usage",
|
||||
"encrypted"
|
||||
],
|
||||
"name": "venice-chat/openai-gpt-54-mini-preserves-reasoning-through-a-tool-loop-and-follow-up",
|
||||
"recordedAt": "2026-10-05T03:01:10.096Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for a code. Only this tool knows the rate.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"code\":{\"type\":\"string\",\"enum\":[\"ZEBRA\",\"MOOSE\"]}},\"required\":[\"code\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating private data**\\n\\nI\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" follow\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" developer\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"'s\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" instructions\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" and\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" use\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" provided\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" tool\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" handle\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" private\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" data\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\".\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312efd07487d28fc51d6572174c85\",\"object\":\"chat.completion.chunk\",\"created\":1791169264,\"model\":\"openai-gpLine truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\",\"id\":\"rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\",\"format\":\"openai-responses-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating private data**\\n\\nI need to follow the developer's instructions and use the provided tool to handle private data. First, I should look up the rate for \\\"ZEBRA\\\" before I answer. After that, I’ll multiply the rate by 17 and then add 9 to get the required number. It’s important to give only the requested number and avoid making another tool call. I’ll likely use the commentary channel for this tool call.\\n\\n__ENCRYPTED_REASONING__id=rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\\ngAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\"},{\"role\":\"tool\",\"tool_call_id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"description\":\"Retrieve the private conversion rate for aLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating a simple math problem**\\n\\nI'm\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" working\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" through\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" math\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\":\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" first\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" I\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169266,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" multiply\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 23\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" by\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 17\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" and\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" that\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" gives\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" me\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f2137487d2aa3a1f180cbf07eb\",\"object\":\"chat.completion.chunk\",\"created\":1791169267,\"model\":\"openai-gpt-54-mini\"Line truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-54-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.\"},{\"role\":\"user\",\"content\":\"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"type\":\"function\",\"function\":{\"name\":\"lookup_rate\",\"arguments\":\"{\\\"code\\\":\\\"ZEBRA\\\"}\"}}],\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\",\"id\":\"rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\",\"format\":\"openai-responses-v1\",\"index\":0}],\"reasoning_content\":\"**Calculating private data**\\n\\nI need to follow the developer's instructions and use the provided tool to handle private data. First, I should look up the rate for \\\"ZEBRA\\\" before I answer. After that, I’ll multiply the rate by 17 and then add 9 to get the required number. It’s important to give only the requested number and avoid making another tool call. I’ll likely use the commentary channel for this tool call.\\n\\n__ENCRYPTED_REASONING__id=rs_014406f0f45e19c6016ac312f01d2487d2a5f2bdb7fbc42f76\\ngAAAAABqwxLxEN4vtmI2WcZ_aDDttdmPwAHgnV2gLZBo_f_mmTdGm4oZzE2JVzcj72AeXHyABmmo2VQN4nXaZK9GSSUZRlYL8mECMxKTj9AMgL8zlnuN5WDexxU-e9SduGP36RN9nH56-go4dz8eu5DEMvMf_TMoTnxz5AnC_UWbty63Vp8jEgkD-DoRjd0cg9LjthP_FLyMvP2Wj9qMJxEuO7Mw2_Pu4I3YeChnygLvmRfRR2QkamL5vjb9yHkl0Lb502vEGB2DW_czNEpHxisifvwRBjA7vk0mbn2dlhiR80aNHO71hC-QqrtUfPXx2HIbVLfbPdJf0saI0wD7ZuCJSQCBfB7fMCnj2ZLQeXa67lgXwsZDLBg4HmSJFvsMinx1IHIjUk7iebsKvmYx40cjqpXL3wj7htZWk2hO9OyGGUwlRsn5THdhTsgwg2SPbuHxb-sLPQnEFZ7bRp5lsVm31X2dlHDa1uZ7KLJZyy9g2CLIKsBwFFoCK7jY3HBXfL21ac5TAkOzVmz-XKl6OMPSl-PSdByOO15uHHpyOcJvJvuaea5Tj1nihtuDlhUP5EQPi7MiWpJMFo9AhDCafAbaQOEF5W8AQPeb_GXvPTzhySRAC8PxrhraEcZ4fjpjLqkv6mi_A1YHwbHEHHWjcK5VEKc3dadlIM_s6djclFBocVXrvnQQElVhWBDM-RtK9sgR07BFj5LVk6fYvg0b6gQnbDuQH18jhtPoj0SX0lN921XEQ1ebMCWGLANND2ew_wyNShYouJohefLULicWG57xh_yV1KEG1ufBedq2CPvCHA9Hm5UssbeJx5yBriyUA2Yh4D7j2UBT_KDWEHydDLQ4mWEyLTfQn0ZB3ZOqkU79z3HpK2w_VHUnkl5nEYEU2bmF5lsG33B4gtbQIBtZT76-fizXsvVZx__taOWXg96VMi3CD3DY0TS76B4U92KFmAFJ4aeiM8bkPuzf1Qd202EWE39CotUDoxAM6mzQu0NObTDnGQE776P6kzUoIKUmP8sDwYG5xot8ZTuXcBYysvj4-pFC_fvqMhsdfgonbke78SIjHtmePsOkfHBnCjSSUFKEbaM0nm8jJ6nYVwiYFuXBKD31QD462TAC7ORVIn32sWSgV2t3Sd7vq4ws2Yy7nUoILRAt_NYQUEXt7_R8J5YWj1L6Ruagk7Vtt-CAWxT6FhVyU4JhMb6-tJrYHgdmtunYrjpHBiOVdQR25aOmLbc_WlDvpO4azcRazpgkASAjPaaDycYceKcheiyUDBgFrvcSYrS1fJb6YZrTzOKw1Ztd0YsklS4nc3jffLNLrBQVvnkajj0OHjGcW8wkP93TCMJyt8tfIH0Rd5hIZLY5EqUWj9yZgXO7CuDKQZIEI0oZXef3mjfQqH8Q9rZsSgS3DI3mCF3CN003R_ZKKLGWkG8VzwlvdocNxaV_Z-ujp_O0YMyOcmEoTJI-j3JP3nefNboyP7EWND6VWDjJbqg64dvu0PK0EW5L0lnAu0l-WVb2qmXLYao_7bOlD8JLFZOyUgHAWmetmnm0NuxzevJEVBkNUu1jQatPzf2Jzf9SGKM_3bM_z0TM5McCZ2Pikzwp96QlaiEEWV_j2g02PT5Y3td-lfpoKxoFD5tltGNY9TlPzK-4a9Iz0hloiCprxWMLGKw2JzctrPF1xr42PKGNgPE84xAQ3sQ4mQ==\"},{\"role\":\"tool\",\"tool_call_id\":\"call_onp7EArhOgXu0hGRF6Y5WHLu\",\"content\":\"{\\\"code\\\":\\\"ZEBRA\\\",\\\"rate\\\":23}\"},{\"role\":\"assistant\",\"content\":\"400\",\"reasoning_details\":[{\"type\":\"reasoning.encrypted\",\"data\":\"gAAAAABqwxL0Nl6Jl5EZuOLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"**Calculating a simple addition**\\n\\nThe\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" user\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" wants\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" me\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" add\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 7\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" previous\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" final\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" answer\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\",\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" which\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" was\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" 400\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\".\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" This\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"resp_014406f0f45e19c6016ac312f49b9c87d2afc60121b8910612\",\"object\":\"chat.completion.chunk\",\"created\":1791169269,\"model\":\"openai-gpt-54-mini\",\"Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"text",
|
||||
"reasoning",
|
||||
"toggle"
|
||||
],
|
||||
"name": "venice-chat/qwen-disables-reasoning",
|
||||
"recordedAt": "2026-10-05T02:59:04.614Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3-6-27b\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 23 multiplied by 17 plus 9? Reply with just the number.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"reasoning\":{\"enabled\":false},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"4\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"0\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"0\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":null,\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}]}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxYUpStKYw7YgEZcXjsb2dOt\",\"object\":\"chat.completion.chunk\",\"created\":1791169144,\"model\":\"qwen3-6-27b\",\"choices\":[],\"usage\":{\"prompt_tokens\":32,\"completion_tokens\":4,\"total_tokens\":36},\"cost\":{\"usd\":0.0000234,\"diem\":0}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:venice-chat",
|
||||
"provider:venice",
|
||||
"protocol:venice-chat",
|
||||
"error"
|
||||
],
|
||||
"name": "venice-chat/surfaces-venice-model-errors",
|
||||
"recordedAt": "2026-10-05T02:59:11.768Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.venice.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"no-such-model-xyz\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"venice_parameters\":{\"include_venice_system_prompt\":false}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 404,
|
||||
"headers": {
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
},
|
||||
"body": "{\"error\":\"Specified model not found: no-such-model-xyz. Did you mean: z-ai-glm-5-3, z-ai-glm-5-3-flash, z-ai-glm-5-turbo?\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { Venice } from "../../src/providers.js"
|
||||
|
||||
LLM.request({
|
||||
model: Venice.chat("qwen3-6-27b"),
|
||||
providerOptions: { reasoningEffort: "high", reasoning: { summary: "concise" }, veniceParameters: { includeVeniceSystemPrompt: false } },
|
||||
})
|
||||
LLM.request({
|
||||
model: Venice.configure({ providerOptions: { reasoningEffort: "future-effort" } }).model("future-model"),
|
||||
providerOptions: { reasoning: { enabled: false }, promptCacheRetention: "future-retention", parallelToolCalls: false },
|
||||
})
|
||||
LLM.request({
|
||||
model: Venice.chat("qwen3-6-27b"),
|
||||
// @ts-expect-error Thinking toggles are boolean.
|
||||
providerOptions: { reasoning: { enabled: "false" } },
|
||||
})
|
||||
LLM.request({
|
||||
model: Venice.chat("qwen3-6-27b"),
|
||||
// @ts-expect-error Venice uses nested reasoning, not Anthropic thinking controls.
|
||||
providerOptions: { thinking: { type: "disabled" } },
|
||||
})
|
||||
LLM.request({
|
||||
model: Venice.chat("qwen3-6-27b"),
|
||||
// @ts-expect-error Venice's system-prompt toggle is boolean.
|
||||
providerOptions: { veniceParameters: { includeVeniceSystemPrompt: "false" } },
|
||||
})
|
||||
@@ -431,42 +431,115 @@ describe("Anthropic Messages route", () => {
|
||||
(yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
|
||||
messages: [
|
||||
Message.user("Start."),
|
||||
Message.assistant("One."),
|
||||
Message.system("Update."),
|
||||
Message.assistant("Two."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)).body.messages,
|
||||
).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Before." },
|
||||
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
|
||||
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Start." }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "One." }] },
|
||||
{ role: "user", content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Two." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
|
||||
it.effect("moves system updates to the next assistant turn and sends consecutive updates together", () =>
|
||||
Effect.gen(function* () {
|
||||
const lower = (messages: ReadonlyArray<Message>) =>
|
||||
compileRequest(LLM.request({ model: opus48, messages: [...messages], cache: "none" })).pipe(
|
||||
Effect.map((prepared) => prepared.body.messages),
|
||||
)
|
||||
const system = (text: string) => ({ role: "system", content: [{ type: "text", text, cache_control: undefined }] })
|
||||
const user = (text: string) => ({ role: "user", content: [{ type: "text", text }] })
|
||||
const assistant = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] })
|
||||
|
||||
expect(
|
||||
yield* lower([
|
||||
Message.user("Fix it."),
|
||||
Message.assistant("Done."),
|
||||
Message.system("Update."),
|
||||
Message.user("Next."),
|
||||
]),
|
||||
).toEqual([user("Fix it."), assistant("Done."), user("Next."), system("Update.")])
|
||||
expect(yield* lower([Message.user("Before."), Message.system("One."), Message.system("Two.")])).toEqual([
|
||||
user("Before."),
|
||||
system("One."),
|
||||
system("Two."),
|
||||
])
|
||||
expect(
|
||||
yield* lower([
|
||||
Message.user("Fix it."),
|
||||
Message.assistant("Done."),
|
||||
Message.system("One."),
|
||||
Message.user("Next."),
|
||||
Message.system("Two."),
|
||||
Message.assistant("After."),
|
||||
]),
|
||||
).toEqual([
|
||||
user("Fix it."),
|
||||
assistant("Done."),
|
||||
user("Next."),
|
||||
system("One."),
|
||||
system("Two."),
|
||||
assistant("After."),
|
||||
])
|
||||
expect(
|
||||
yield* lower([
|
||||
Message.user("Use the tool."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Update."),
|
||||
Message.user("Also check tests."),
|
||||
]),
|
||||
).toEqual([
|
||||
user("Use the tool."),
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
|
||||
user("Also check tests."),
|
||||
system("Update."),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps wrapped system updates in place for models without native system updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertexOpus48,
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
Message.user("Fix it."),
|
||||
Message.assistant("Done."),
|
||||
Message.system("Update."),
|
||||
Message.user("Next."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Fix it." }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Done." }] },
|
||||
{ role: "user", content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Next." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends Vertex system updates after local tool results as native system messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const toolTurn = [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
]
|
||||
const lowered = [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -477,55 +550,23 @@ describe("Anthropic Messages route", () => {
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nOperator update.\n</system-update>",
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
{ role: "system", content: [{ type: "text", text: "Operator update.", cache_control: undefined }] },
|
||||
]
|
||||
|
||||
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
const terminal = yield* compileRequest(LLM.request({ model: vertexOpus48, messages: toolTurn, cache: "none" }))
|
||||
const history = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertexOpus48,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
Message.assistant("Acknowledged."),
|
||||
Message.user("Next step."),
|
||||
],
|
||||
messages: [...toolTurn, Message.assistant("Acknowledged."), Message.user("Next step.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: '"Done."',
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nOperator update.\n</system-update>",
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
expect(terminal.body.messages).toEqual(lowered)
|
||||
expect(history.body.messages).toEqual([
|
||||
...lowered,
|
||||
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Next step." }] },
|
||||
])
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { Venice } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.VENICE_API_KEY ?? "fixture"
|
||||
const recorded = recordedTests({
|
||||
prefix: "venice-chat",
|
||||
provider: "venice",
|
||||
protocol: "venice-chat",
|
||||
requires: ["VENICE_API_KEY"],
|
||||
})
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup_rate",
|
||||
description: "Retrieve the private conversion rate for a code. Only this tool knows the rate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { code: { type: "string", enum: ["ZEBRA", "MOOSE"] } },
|
||||
required: ["code"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
for (const item of [
|
||||
{ model: "deepseek-v4-flash", reasoning: "scalar" },
|
||||
{ model: "claude-opus-4-8", reasoning: "signed", effort: "high" },
|
||||
{ model: "gemini-3-1-pro-preview", reasoning: "details", effort: "high" },
|
||||
{ model: "openai-gpt-54-mini", reasoning: "encrypted" },
|
||||
] as const) {
|
||||
recorded.effect.with(
|
||||
`${item.model} preserves reasoning through a tool loop and follow-up`,
|
||||
{
|
||||
tags: ["reasoning", "tool", "tool-loop", "continuation", "usage", item.reasoning],
|
||||
metadata: { model: item.model },
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: Venice.configure({ apiKey }).chat(item.model),
|
||||
system:
|
||||
"Use the provided tool for private data. After receiving the result, give only the requested number; do not call the tool again.",
|
||||
prompt:
|
||||
"Look up conversion rate for code ZEBRA. Then multiply that rate by 17 and add 9. You must use lookup_rate before answering.",
|
||||
tools: [lookup],
|
||||
generation: { maxTokens: 4096 },
|
||||
providerOptions: "effort" in item ? { reasoningEffort: item.effort } : undefined,
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.toolCalls).toMatchObject([{ name: "lookup_rate", input: { code: "ZEBRA" } }])
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
const continuation = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { code: "ZEBRA", rate: 23 } }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const compiled = yield* compileRequest(continuation)
|
||||
const assistant = compiled.body.messages.find((message) => message.role === "assistant")
|
||||
expect(assistant).toBeDefined()
|
||||
// Some models call a tool immediately and only reason after its result.
|
||||
if (item.reasoning === "scalar" && first.reasoning.length > 0) {
|
||||
expect(assistant).toMatchObject({ reasoning_content: first.reasoning })
|
||||
}
|
||||
if (item.reasoning === "signed") {
|
||||
const details = first.message.content
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? [])
|
||||
expect(details).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "reasoning.text", signature: expect.any(String) }),
|
||||
]),
|
||||
)
|
||||
expect(assistant).toMatchObject({ reasoning_details: details })
|
||||
}
|
||||
if (item.reasoning === "details")
|
||||
expect(assistant).toMatchObject({
|
||||
reasoning_details: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "reasoning.encrypted", data: expect.any(String) }),
|
||||
]),
|
||||
})
|
||||
if (item.reasoning === "encrypted") {
|
||||
expect(first.reasoning).not.toContain("__ENCRYPTED_REASONING__")
|
||||
const details = first.message.content
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? [])
|
||||
if (details.length > 0) expect(assistant).toMatchObject({ reasoning_details: details })
|
||||
}
|
||||
const second = yield* LLMClient.generate(continuation)
|
||||
expect(second.text).toContain("400")
|
||||
expect(second.reasoning).not.toContain("__ENCRYPTED_REASONING__")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(second.usage?.outputTokens).toBeGreaterThan(0)
|
||||
const followUp = LLMRequest.update(continuation, {
|
||||
messages: [
|
||||
...continuation.messages,
|
||||
second.message,
|
||||
Message.user("Add 7 to your previous final answer. Reply with just the number."),
|
||||
],
|
||||
})
|
||||
const third = yield* LLMClient.generate(followUp)
|
||||
expect(third.text).toContain("407")
|
||||
expect(third.finishReason.normalized).toBe("stop")
|
||||
if (item.reasoning === "encrypted") {
|
||||
expect(
|
||||
[first, second, third].flatMap((response) =>
|
||||
response.message.content
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.flatMap((part) => part.providerMetadata?.venice?.reasoningDetails ?? []),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "reasoning.encrypted", data: expect.any(String) }),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}),
|
||||
180_000,
|
||||
)
|
||||
}
|
||||
|
||||
recorded.effect.with(
|
||||
"Gemini preserves per-call signatures through parallel tools",
|
||||
{ tags: ["tool", "parallel", "signature"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: Venice.configure({ apiKey }).chat("gemini-3-8-flash"),
|
||||
prompt:
|
||||
"Look up private rates for both ZEBRA and MOOSE using two calls to lookup_rate. Then add the rates and multiply by 7. Use parallel calls if possible.",
|
||||
tools: [lookup],
|
||||
providerOptions: { reasoningEffort: "high", parallelToolCalls: true },
|
||||
generation: { maxTokens: 4096 },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.toolCalls).toHaveLength(2)
|
||||
const continuation = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: {
|
||||
rate:
|
||||
Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }))(call.input).code === "ZEBRA"
|
||||
? 23
|
||||
: 31,
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
})
|
||||
const compiled = yield* compileRequest(continuation)
|
||||
expect(compiled.body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tool_calls: first.toolCalls.map((call) =>
|
||||
expect.objectContaining({
|
||||
id: call.id,
|
||||
...(call.providerMetadata?.venice?.thoughtSignature === undefined
|
||||
? {}
|
||||
: { thought_signature: call.providerMetadata.venice.thoughtSignature }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const second = yield* LLMClient.generate(continuation)
|
||||
expect(second.text).toContain("378")
|
||||
const signature = second.message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.providerMetadata?.venice?.messageThoughtSignature)
|
||||
.find((value) => value !== undefined)
|
||||
expect(signature).toEqual(expect.any(String))
|
||||
const followUp = LLMRequest.update(continuation, {
|
||||
messages: [
|
||||
...continuation.messages,
|
||||
second.message,
|
||||
Message.user("Add 7 to your previous final answer. Reply with just the number."),
|
||||
],
|
||||
})
|
||||
expect((yield* compileRequest(followUp)).body.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "assistant", thought_signature: signature })]),
|
||||
)
|
||||
expect((yield* LLMClient.generate(followUp)).text).toContain("385")
|
||||
}),
|
||||
180_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"Qwen disables reasoning",
|
||||
{ tags: ["text", "reasoning", "toggle"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Venice.configure({ apiKey }).chat("qwen3-6-27b"),
|
||||
prompt: "What is 23 multiplied by 17 plus 9? Reply with just the number.",
|
||||
providerOptions: { reasoning: { enabled: false } },
|
||||
generation: { maxTokens: 4096 },
|
||||
}),
|
||||
)
|
||||
expect(response.text).toContain("400")
|
||||
expect(response.reasoning).toBe("")
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
|
||||
recorded.effect.with("surfaces Venice model errors", { tags: ["error"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({ model: Venice.configure({ apiKey }).chat("no-such-model-xyz"), prompt: "Hello" }),
|
||||
).pipe(Effect.result)
|
||||
expect(result._tag).toBe("Failure")
|
||||
if (result._tag === "Failure") {
|
||||
expect(result.failure.reason._tag).toBe("InvalidRequest")
|
||||
expect(result.failure.message).toContain("Specified model not found")
|
||||
expect(result.failure.reason.http?.status).toBe(404)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,370 @@
|
||||
import { expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth, LLM, LLMClient, Message } from "../../src/index.js"
|
||||
import { OpenAI, Venice } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = Venice.configure({ apiKey: "fixture" }).chat("fixture-model")
|
||||
const frame = (delta: Record<string, unknown>, finish_reason: string | null = null) => ({
|
||||
choices: [{ delta, finish_reason }],
|
||||
})
|
||||
|
||||
it.effect("Venice owns its endpoint, environment auth, aliases, and SDK-compatible prompt default", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Venice.provider.model).toBe(Venice.provider.chat)
|
||||
const selected = Venice.chat("qwen3-6-27b")
|
||||
const request = LLM.request({ model: selected, prompt: "Hello", promptCacheKey: "session-123" })
|
||||
const compiled = yield* compileRequest(request)
|
||||
const url = Endpoint.render(selected.route.endpoint, { request, body: compiled.body }).toString()
|
||||
expect(url).toBe("https://api.venice.ai/api/v1/chat/completions")
|
||||
expect(selected.provider).toBe("venice")
|
||||
expect(compiled.body.venice_parameters).toEqual({ include_venice_system_prompt: false })
|
||||
expect(compiled.body.prompt_cache_key).toBe("session-123")
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.stream_options).toEqual({ include_usage: true })
|
||||
const headers = yield* selected.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
expect(headers.authorization).toBe("Bearer fixture")
|
||||
const custom = Venice.configure({
|
||||
baseURL: "https://gateway.example/v1",
|
||||
queryParams: { deployment: "private" },
|
||||
auth: Auth.header("x-key", "fixture"),
|
||||
}).chat("qwen3-6-27b")
|
||||
expect(Endpoint.render(custom.route.endpoint, { request, body: compiled.body }).toString()).toBe(
|
||||
"https://gateway.example/v1/chat/completions?deployment=private",
|
||||
)
|
||||
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { VENICE_API_KEY: "fixture" } })))),
|
||||
)
|
||||
|
||||
it.effect("Venice lowers typed options, model defaults, and explicit reasoning overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Venice.configure({ apiKey: "fixture", providerOptions: { reasoningEffort: "low" } }).model(
|
||||
"qwen3-6-27b",
|
||||
),
|
||||
generation: { maxTokens: 200, topK: 20 },
|
||||
providerOptions: {
|
||||
reasoningEffort: "future-effort",
|
||||
reasoning: { effort: "high", enabled: true, summary: "concise" },
|
||||
veniceParameters: { includeVeniceSystemPrompt: true, enableWebSearch: "auto", stripThinkingResponse: false },
|
||||
parallelToolCalls: false,
|
||||
maxCompletionTokens: 123,
|
||||
minP: 0.1,
|
||||
repetitionPenalty: 1.1,
|
||||
stopTokenIds: [12],
|
||||
promptCacheRetention: "24h",
|
||||
logprobs: true,
|
||||
topLogprobs: 2,
|
||||
minTemp: 0.1,
|
||||
maxTemp: 1.5,
|
||||
responseFormat: { type: "json_object" },
|
||||
user: "user-123",
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 123,
|
||||
top_k: 20,
|
||||
min_p: 0.1,
|
||||
repetition_penalty: 1.1,
|
||||
reasoning: { effort: "future-effort", enabled: true, summary: "concise" },
|
||||
venice_parameters: {
|
||||
include_venice_system_prompt: true,
|
||||
enable_web_search: "auto",
|
||||
strip_thinking_response: false,
|
||||
},
|
||||
parallel_tool_calls: false,
|
||||
stop_token_ids: [12],
|
||||
prompt_cache_retention: "24h",
|
||||
logprobs: true,
|
||||
top_logprobs: 2,
|
||||
min_temp: 0.1,
|
||||
max_temp: 1.5,
|
||||
response_format: { type: "json_object" },
|
||||
user: "user-123",
|
||||
})
|
||||
expect(compiled.body.reasoning_effort).toBeUndefined()
|
||||
expect(compiled.body.max_tokens).toBeUndefined()
|
||||
expect(
|
||||
(yield* compileRequest(LLM.request({ model, providerOptions: { reasoning: { enabled: false } } }))).body
|
||||
.reasoning,
|
||||
).toEqual({ enabled: false })
|
||||
expect(
|
||||
(yield* compileRequest(LLM.request({ model, cache: "none", promptCacheKey: "session-123" }))).body
|
||||
.prompt_cache_key,
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice hides encrypted trailers across every marker split while preserving exact replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const marker = "__ENCRYPTED_REASONING__"
|
||||
for (const split of Array.from({ length: marker.length + 1 }, (_, index) => index)) {
|
||||
const raw = `Visible summary\n${marker}id=rs_123\nopaque-payload`
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
frame({ reasoning_content: `Visible summary\n${marker.slice(0, split)}` }),
|
||||
frame({
|
||||
reasoning_content: `${marker.slice(split)}id=rs_123\nopaque-payload`,
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "opaque-payload",
|
||||
format: "openai-responses-v1",
|
||||
id: "rs_123",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
frame({ content: "42" }, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.reasoning).toBe("Visible summary\n")
|
||||
expect(
|
||||
response.events
|
||||
.filter((event) => event.type === "reasoning-delta")
|
||||
.map((event) => event.text)
|
||||
.join(""),
|
||||
).toBe("Visible summary\n")
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Hello"), response.message] }))
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
reasoning_content: raw,
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "opaque-payload",
|
||||
format: "openai-responses-v1",
|
||||
id: "rs_123",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice preserves opaque-only reasoning and does not eat an incomplete marker prefix", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const scalar of ["__ENCRYPTED_REASONING__opaque", "normal _", "normal __ENCRYPTED_"]) {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(frame({ reasoning_content: scalar }), frame({ content: "42" }, "stop"))),
|
||||
),
|
||||
)
|
||||
expect(response.reasoning).toBe(scalar.startsWith("__ENCRYPTED_REASONING__") ? "" : scalar)
|
||||
const body = (yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body
|
||||
expect(body.messages).toEqual(expect.arrayContaining([expect.objectContaining({ reasoning_content: scalar })]))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice encrypted trailers do not change the base OpenAI Chat protocol", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).chat("gpt-4o"), prompt: "Hello" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
frame({ reasoning_content: "text __ENCRYPTED_REASONING__payload" }),
|
||||
frame({ content: "42" }, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.tap((response) => Effect.sync(() => expect(response.reasoning).toBe("text __ENCRYPTED_REASONING__payload"))),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("Venice keeps late Gemini signatures attached to the correct parallel call", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Lookup" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
frame({
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_0", function: { name: "lookup", arguments: '{"key":"A"}' } },
|
||||
{ index: 1, id: "call_1", function: { name: "lookup", arguments: '{"key":"B"}' } },
|
||||
],
|
||||
}),
|
||||
frame({ tool_calls: [{ index: 0, thought_signature: "signed-first-call" }] }),
|
||||
frame({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.toolCalls).toHaveLength(2)
|
||||
expect(response.toolCalls[0].providerMetadata?.venice?.thoughtSignature).toBe("signed-first-call")
|
||||
expect(response.toolCalls[1].providerMetadata?.venice?.thoughtSignature).toBeUndefined()
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [Message.user("Lookup"), response.message] }))
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tool_calls: [
|
||||
expect.objectContaining({ id: "call_0", thought_signature: "signed-first-call" }),
|
||||
expect.not.objectContaining({ thought_signature: expect.anything() }),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice preserves message signatures even for identical answers and empty intervening messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(frame({ content: "42" }), frame({ thought_signature: "second-signature" }), frame({}, "stop")),
|
||||
),
|
||||
),
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "42", providerMetadata: { venice: { messageThoughtSignature: "first-signature" } } },
|
||||
]),
|
||||
Message.assistant(""),
|
||||
response.message,
|
||||
],
|
||||
})
|
||||
expect((yield* compileRequest(request)).body.messages).toEqual([
|
||||
{ role: "assistant", content: "42", thought_signature: "first-signature" },
|
||||
{ role: "assistant", content: "42", thought_signature: "second-signature" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice merges signed Claude reasoning without losing its final signature", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
frame({
|
||||
reasoning_content: "Think ",
|
||||
reasoning_details: [{ type: "reasoning.text", text: "Think ", format: "anthropic-claude-v1", index: 0 }],
|
||||
}),
|
||||
frame({
|
||||
reasoning_content: "carefully",
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: "carefully", format: "anthropic-claude-v1", index: 0 },
|
||||
],
|
||||
}),
|
||||
frame({
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.text",
|
||||
text: "",
|
||||
signature: "claude-signature",
|
||||
format: "anthropic-claude-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
frame({ content: "42" }, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.reasoning).toBe("Think carefully")
|
||||
expect((yield* compileRequest(LLM.request({ model, messages: [response.message] }))).body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.text",
|
||||
text: "Think carefully",
|
||||
signature: "claude-signature",
|
||||
format: "anthropic-claude-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Venice maps cache-write usage and retains the raw wire spelling", () =>
|
||||
LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(frame({ content: "42" }, "stop"), {
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 40, cache_creation_input_tokens: 30 },
|
||||
completion_tokens_details: { reasoning_tokens: 10 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.tap((response) =>
|
||||
Effect.sync(() => {
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
nonCachedInputTokens: 30,
|
||||
cacheReadInputTokens: 40,
|
||||
cacheWriteInputTokens: 30,
|
||||
reasoningTokens: 10,
|
||||
})
|
||||
expect(response.usage?.providerMetadata?.venice).toMatchObject({
|
||||
prompt_tokens_details: { cache_creation_input_tokens: 30 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("Venice surfaces HTTP string errors and streaming validation details without losing the payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse('{"error":"Specified model not found"}', {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
Effect.result,
|
||||
)
|
||||
expect(http._tag).toBe("Failure")
|
||||
if (http._tag === "Failure") expect(http.failure.message).toContain("Specified model not found")
|
||||
const payload = {
|
||||
error: "Invalid request parameters",
|
||||
issues: [{ message: "Unsupported value", path: ["reasoning", "effort"] }],
|
||||
}
|
||||
const streamed = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(payload))),
|
||||
Effect.result,
|
||||
)
|
||||
expect(streamed._tag).toBe("Failure")
|
||||
if (streamed._tag === "Failure") {
|
||||
expect(streamed.failure.message).toContain("reasoning.effort: Unsupported value")
|
||||
expect(JSON.parse(streamed.failure.reason.body ?? "{}")).toEqual(payload)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -5,11 +5,149 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => {
|
||||
const prompts: unknown[] = []
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwQuestions",
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: (input) => {
|
||||
generations.push(input)
|
||||
|
||||
return {
|
||||
text: input.prompt.includes("second question")
|
||||
? "Second answer"
|
||||
: `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await editor.fill("/btw first question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0)
|
||||
await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded()
|
||||
await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport()
|
||||
await editor.fill("/btw second question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
const first = page.getByRole("tab", { name: "first question", exact: true })
|
||||
const second = page.getByRole("tab", { name: "second question", exact: true })
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") })
|
||||
expect(generations).toHaveLength(2)
|
||||
expect(generations[1]?.prompt).not.toContain("first question")
|
||||
expect(generations[1]?.prompt).not.toContain("First answer")
|
||||
|
||||
await page.reload()
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await first.click()
|
||||
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath("restored-answer.png") })
|
||||
|
||||
// Closing a tab forgets its question; the other tab and its answer survive a reload.
|
||||
await first.click({ button: "middle" })
|
||||
await expect(first).toHaveCount(0)
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await expect(first).toHaveCount(0)
|
||||
expect(generations).toHaveLength(2)
|
||||
expect(prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => {
|
||||
const held = Promise.withResolvers<void>()
|
||||
const attempts: string[] = []
|
||||
const prompts: unknown[] = []
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwConcurrent",
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
attempts.push(input.prompt)
|
||||
|
||||
if (input.prompt.includes("slow question") && attempts.length === 1) {
|
||||
await held.promise
|
||||
|
||||
return { text: "Abandoned answer" }
|
||||
}
|
||||
|
||||
return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" }
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await editor.fill("/btw slow question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await editor.fill("/btw fast question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "slow question", exact: true }).click()
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0)
|
||||
|
||||
await page.reload()
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("status")).toHaveCount(0)
|
||||
expect(attempts).toHaveLength(2)
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
|
||||
expect(attempts).toHaveLength(3)
|
||||
expect(attempts[2]).toContain("slow question")
|
||||
expect(attempts[2]).not.toContain("fast question")
|
||||
held.resolve()
|
||||
await page.getByRole("tab", { name: "fast question", exact: true }).click()
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "slow question", exact: true }).click()
|
||||
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
expect(prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 900, height: 700 })
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwOverflow",
|
||||
generate: () => ({ text: "Saved overflow answer" }),
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`)
|
||||
|
||||
for (const question of questions) {
|
||||
await editor.fill(`/btw ${question}`)
|
||||
await editor.press("Enter")
|
||||
await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
|
||||
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") })
|
||||
})
|
||||
|
||||
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
const abandoned = Promise.withResolvers<void>()
|
||||
const leftBehind = Promise.withResolvers<void>()
|
||||
const main = { id: "ses_btw_sidebar", title: "Side question session" }
|
||||
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
|
||||
const ownerWarnings: string[] = []
|
||||
@@ -28,7 +166,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
|
||||
|
||||
if (input.prompt.includes("left behind")) {
|
||||
await abandoned.promise
|
||||
await leftBehind.promise
|
||||
|
||||
return { text: "This answer arrived after the user left." }
|
||||
}
|
||||
@@ -47,7 +185,6 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/btw ")
|
||||
await editor.press("Enter")
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
|
||||
@@ -59,7 +196,10 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
|
||||
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
generated.resolve()
|
||||
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
@@ -75,13 +215,11 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await editor.fill("/btw what belongs here?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
// Leaving a session abandons its in-flight question, so it reads as failed on return.
|
||||
await editor.fill("/btw is this question left behind?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
@@ -89,13 +227,35 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await expectSessionTitle(page, other.title)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
|
||||
abandoned.resolve()
|
||||
// Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives.
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
leftBehind.resolve()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0)
|
||||
expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("status")).toHaveCount(0)
|
||||
await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
@@ -30,9 +30,11 @@ for (const position of ["top", "bottom"] as const) {
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
@@ -40,9 +42,17 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
|
||||
if (position === "top") {
|
||||
const titlebar = page.locator('[data-slot="titlebar-v2"]')
|
||||
await expect(titlebar).toHaveCSS("padding-top", "16px")
|
||||
await expect(titlebar).toHaveCSS("height", "44px")
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -51,6 +61,7 @@ for (const position of ["top", "bottom"] as const) {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -149,16 +160,19 @@ for (const position of ["top", "bottom"] as const) {
|
||||
|
||||
// The view resets to Session whenever the routed session changes, including through Home.
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
|
||||
const openTabs = async () => {
|
||||
await trigger.click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
}
|
||||
|
||||
const openTab = async (title: string) => {
|
||||
await openTabs()
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(title)
|
||||
}
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
@@ -338,6 +352,7 @@ test.describe("touch", () => {
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
@@ -66,6 +66,72 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
}
|
||||
}
|
||||
|
||||
test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = "/projects/existing-worktree"
|
||||
const sessionID = "ses_location_not_found"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const moves: unknown[] = []
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/location?**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
requests++
|
||||
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
|
||||
await expect(status).toContainText(directory)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
// A typed not-found answer is final; retrying cannot succeed until the folder changes.
|
||||
expect(requests).toBe(1)
|
||||
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await expect.poll(() => moves).toEqual([{ directory: destination }])
|
||||
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_location_not_found_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/old-tree"
|
||||
|
||||
@@ -859,6 +859,41 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(pending).toContainText(followUp)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
const bubble = pending.locator('[data-slot="user-message-text"]')
|
||||
const colors =
|
||||
delivery === "steer"
|
||||
? {
|
||||
delivered: await userRow(page, userID)
|
||||
.locator('[data-slot="user-message-text"]')
|
||||
.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
text: getComputedStyle(element).color,
|
||||
})),
|
||||
pending: await bubble.evaluate((element) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
|
||||
probe.style.color = "var(--v2-text-text-base)"
|
||||
element.appendChild(probe)
|
||||
const colors = {
|
||||
background: getComputedStyle(probe).backgroundColor,
|
||||
text: getComputedStyle(probe).color,
|
||||
}
|
||||
probe.remove()
|
||||
return colors
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (colors) {
|
||||
await expect(bubble).toHaveCSS("background-color", colors.pending.background)
|
||||
await expect(bubble).toHaveCSS("color", colors.pending.text)
|
||||
await pending.hover()
|
||||
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(
|
||||
/^Pending\s*·\s*Build\s*·\s*Queue Model$/,
|
||||
)
|
||||
await bubble.evaluate((element) => ((element as HTMLElement).dataset.deliveryMarker = "pending"))
|
||||
}
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", {
|
||||
sessionID,
|
||||
@@ -921,6 +956,16 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toHaveCount(1)
|
||||
if (colors) {
|
||||
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
|
||||
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Build\s*·\s*Queue Model$/)
|
||||
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)
|
||||
await expect(bubble).toHaveCSS("color", colors.delivered.text)
|
||||
await expect(bubble).toHaveCSS("transition-property", "background-color, color")
|
||||
await transcript.screenshot({ path: testInfo.outputPath("delivered-steer.png") })
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await expect(bubble).toHaveCSS("transition-duration", "0s")
|
||||
}
|
||||
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
|
||||
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
|
||||
"data-message-id",
|
||||
|
||||
@@ -650,6 +650,43 @@ test.describe("Working", () => {
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
["one", "two", "three"].map((name) =>
|
||||
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { timelineDetail: timelinePresets[0].value },
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="read-tool-group"]')
|
||||
const shimmer = group.locator('[data-component="text-shimmer"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
|
||||
await expect(group).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
|
||||
)
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -145,7 +145,7 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("runs a client argument command without admitting it to the session", async () => {
|
||||
test("applies the selection and runs a client argument command without admitting it to the session", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
|
||||
const image = {
|
||||
@@ -169,7 +169,8 @@ describe("Composer submission", () => {
|
||||
}
|
||||
}).submit(new Event("submit"))
|
||||
|
||||
expect(calls).toEqual(["btw"])
|
||||
// Client commands such as /btw generate with the session's model, so the composer's selection commits first.
|
||||
expect(calls).toEqual(["switch-agent", "switch-model", "btw"])
|
||||
expect(history).toEqual([])
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image])
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
|
||||
@@ -66,6 +66,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
submitting.add(input.adapter.state)
|
||||
|
||||
try {
|
||||
// Client commands such as /btw answer with the session's model, so apply the composer's selection first,
|
||||
// following the same steer rule as server commands.
|
||||
const selection = currentSelection(input)
|
||||
|
||||
if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer")
|
||||
await applySelection(
|
||||
input.adapter.session(),
|
||||
selection,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
)
|
||||
clearClientCommand(input, prompt)
|
||||
await clientCommand()
|
||||
} catch (error) {
|
||||
@@ -272,25 +282,22 @@ function readSubmission(
|
||||
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
const selection = currentSelection(input)
|
||||
|
||||
if (!model || !agent) {
|
||||
if (!selection) {
|
||||
input.notify.missingSelection()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const variant = controls.model.selection.variant.current()
|
||||
const retry = input.adapter.state.retry.current()
|
||||
|
||||
const retryID =
|
||||
retry &&
|
||||
retry.agent === agent &&
|
||||
retry.providerID === model.provider.id &&
|
||||
retry.modelID === model.id &&
|
||||
(retry.variant ?? "default") === (variant ?? "default")
|
||||
retry.agent === selection.agent &&
|
||||
retry.providerID === selection.model.providerID &&
|
||||
retry.modelID === selection.model.modelID &&
|
||||
(retry.variant ?? "default") === (selection.variant ?? "default")
|
||||
? retry.id
|
||||
: undefined
|
||||
|
||||
@@ -301,15 +308,25 @@ function readSubmission(
|
||||
context,
|
||||
text,
|
||||
images,
|
||||
selection: {
|
||||
agent,
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant,
|
||||
},
|
||||
selection,
|
||||
delivery: input.delivery?.(alternate) ?? "steer",
|
||||
}
|
||||
}
|
||||
|
||||
function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined {
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
|
||||
if (!model || !agent) return
|
||||
|
||||
return {
|
||||
agent,
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant: controls.model.selection.variant.current(),
|
||||
}
|
||||
}
|
||||
|
||||
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
|
||||
submission.clear()
|
||||
submission.target().mode.set("normal")
|
||||
|
||||
@@ -2,6 +2,13 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
"session.location.worktree": "Choose worktree",
|
||||
"session.location.worktreesFailed": "Failed to load worktrees",
|
||||
"session.location.moving": "Moving session…",
|
||||
"session.location.moveFailed": "Failed to move session",
|
||||
"server.action.authenticate": "Authenticate",
|
||||
"server.status.connecting": "Connecting over SSH…",
|
||||
"server.status.authentication": "Authentication required",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
await server.ctx.sync.worktrees.refresh(projectID)
|
||||
const items = await server.ctx.sync.worktrees.list(projectID)
|
||||
|
||||
if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
|
||||
return items ?? []
|
||||
},
|
||||
// Seed latest so even the first fetch does not enter Suspense.
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const otherWorktrees = () =>
|
||||
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
|
||||
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = useMutation(() => ({
|
||||
mutationFn: async (input: { directory?: string }) => {
|
||||
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
|
||||
const current = project()
|
||||
|
||||
const destination =
|
||||
input.directory ??
|
||||
(current &&
|
||||
(await createWorktree({
|
||||
api: server.ctx.sdk.api,
|
||||
data,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
})))
|
||||
|
||||
if (!destination) return
|
||||
await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.location.moveFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
function choose() {
|
||||
if (move.isPending) return
|
||||
pickDirectory({
|
||||
server: server.conn,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
|
||||
if (selected) move.mutate({ directory: selected })
|
||||
|
||||
if (!selected) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading && otherWorktrees().length === 0}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={otherWorktrees()}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-down-to-line" />}
|
||||
icon={<Icon name="outline-undo" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.undo")}
|
||||
onClick={() => props.queue.undo(props.id)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { SessionUserActions } from "@opencode/session-ui/message"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import type { Region } from "@/runtime/extension/panels"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -285,24 +286,51 @@ export function createActiveSessionRegion(input: {
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
session: SessionModel
|
||||
model: SessionComposerController
|
||||
suggestionBoundary: () => HTMLElement | undefined
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
|
||||
const missing = createMemo(() => {
|
||||
const ref = location().missing
|
||||
const info = props.session.data.info()
|
||||
|
||||
if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return
|
||||
|
||||
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
<Show
|
||||
when={missing()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(current) => (
|
||||
<SessionLocationMissing
|
||||
sessionID={current.sessionID}
|
||||
projectID={current.projectID}
|
||||
directory={current.directory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -35,6 +36,7 @@ export function SessionRunningMenu(props: {
|
||||
const openRoute = useOpenSessionRoute()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
|
||||
|
||||
// Foreground shells stay out: the timeline already shows them at the bottom.
|
||||
@@ -105,7 +107,7 @@ export function SessionRunningMenu(props: {
|
||||
|
||||
return (
|
||||
<Show when={items().length > 0}>
|
||||
<Menu gutter={6} placement="bottom-start">
|
||||
<Menu gutter={6} placement="bottom-start" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
@@ -123,7 +125,12 @@ export function SessionRunningMenu(props: {
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => open(item)}
|
||||
onSelect={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
open(item)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value}>
|
||||
<LocationProvider directory={value()}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
|
||||
@@ -341,7 +341,9 @@ function SessionScreenContent(props: {
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -374,8 +374,9 @@ function MessageTimelineView(
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
// Used groups keep the fallback regardless of disclosure state.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
|
||||
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
|
||||
return false
|
||||
|
||||
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
|
||||
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
|
||||
|
||||
@@ -59,6 +59,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
const location = useLocation()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const bottom = createMemo(() => mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
const mobileTop = createMemo(() => platform.platform === "web" && mobile() && !bottom())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -150,9 +151,17 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
platform.platform === "web"
|
||||
? bottom()
|
||||
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: mobileTop()
|
||||
? "calc(28px + 16px + env(safe-area-inset-top, 0px))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: undefined,
|
||||
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
|
||||
// iOS blurs page content just below the status bar in Home Screen web apps, so keep the phone titlebar row
|
||||
// 16px clear of the safe area, as ChatGPT's phone header does.
|
||||
"padding-top": bottom()
|
||||
? "0px"
|
||||
: mobileTop()
|
||||
? "calc(16px + env(safe-area-inset-top, 0px))"
|
||||
: "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
|
||||
"min-height": minHeight(),
|
||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||
@@ -512,7 +521,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
|
||||
classList={{
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows() && !mobileTop(),
|
||||
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
|
||||
"pl-4": macTrafficLights(),
|
||||
// Center the 20px app icon over the sidebar's 16px icon column.
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode/client/promise"
|
||||
import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
/** Set only when the server reports that this exact Location's folder does not exist. */
|
||||
readonly missing: LocationRef | undefined
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
init: (props: { directory: string; workspaceID?: string }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
directory: props.directory,
|
||||
workspaceID: props.workspaceID,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
|
||||
},
|
||||
)
|
||||
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [state, setState] = createStore<{ missing?: LocationRef }>({})
|
||||
|
||||
createEffect(() => {
|
||||
const location = ref()
|
||||
@@ -35,27 +42,37 @@ const context = createSimpleContext({
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
setState("missing", undefined)
|
||||
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
|
||||
// Generic sync failures do not prove the directory is missing; only the server's typed
|
||||
// LocationNotFoundError does, and retrying it cannot succeed until the folder changes.
|
||||
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
retryIf: (error) => !stale && !isLocationNotFoundError(error),
|
||||
}).catch((error) => {
|
||||
if (stale || !isLocationNotFoundError(error)) return
|
||||
setState("missing", location)
|
||||
})
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
missing: state.missing,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
|
||||
|
||||
export const LocationProvider = context.provider
|
||||
@@ -49,10 +49,12 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
|
||||
const action = {
|
||||
file: language.t("dialog.directory.action.selectFile"),
|
||||
directory: language.t("dialog.directory.action.selectFolder"),
|
||||
}
|
||||
|
||||
const [root, setRoot] = createSignal("")
|
||||
const [input, setInput] = createSignal("")
|
||||
const [selected, setSelected] = createSignal("")
|
||||
@@ -74,31 +76,42 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath()
|
||||
const current = props.location ?? fallbackPath.latest
|
||||
|
||||
return current ? { directory: current.directory } : undefined
|
||||
})
|
||||
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath()?.directory,
|
||||
fallbackPath.latest?.directory,
|
||||
)
|
||||
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = location()?.directory
|
||||
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: location(),
|
||||
@@ -108,48 +121,64 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
|
||||
return {
|
||||
query: value,
|
||||
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
||||
}
|
||||
})
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input()))
|
||||
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
|
||||
const request =
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
const current = location()
|
||||
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
|
||||
if (!nodes) {
|
||||
listings.delete(key)
|
||||
|
||||
if (!key) setError(true)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function navigate(path: string) {
|
||||
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
||||
|
||||
if (!value) return
|
||||
const token = ++navigation
|
||||
setLoading(true)
|
||||
@@ -163,6 +192,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
advanced.clear()
|
||||
tree?.resetPaths([])
|
||||
const valid = await load("", token)
|
||||
|
||||
if (!activeTreeNavigation(token, navigation)) return
|
||||
setRootValid(valid)
|
||||
setLoading(false)
|
||||
@@ -171,9 +201,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function complete() {
|
||||
const items = currentSuggestions()
|
||||
const match = items[activeSuggestion()] ?? items[0]
|
||||
|
||||
if (!match) return
|
||||
const value = displayPickerPath(match.absolute, input(), home())
|
||||
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
||||
|
||||
if (match.type === "file") {
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -184,8 +216,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
||||
if (suggestion.type === "directory") {
|
||||
void navigate(suggestion.absolute)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -199,23 +233,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function activeSuggestionValue() {
|
||||
const items = currentSuggestions()
|
||||
|
||||
return items[activeSuggestion()] ?? items[0]
|
||||
}
|
||||
|
||||
const keyActions: Partial<Record<string, () => void>> = {
|
||||
ArrowDown: () => moveSuggestion(1),
|
||||
ArrowUp: () => moveSuggestion(-1),
|
||||
Enter: () => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
Tab: complete,
|
||||
}
|
||||
const keyActions = new Map([
|
||||
["ArrowDown", () => moveSuggestion(1)],
|
||||
["ArrowUp", () => moveSuggestion(-1)],
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
],
|
||||
["Tab", complete],
|
||||
])
|
||||
|
||||
function handleInputKey(event: KeyboardEvent) {
|
||||
const action = keyActions[event.key]
|
||||
const action = keyActions.get(event.key)
|
||||
|
||||
if (!action) return
|
||||
|
||||
if (event.key === "Tab" && event.shiftKey) return
|
||||
event.preventDefault()
|
||||
action()
|
||||
@@ -223,6 +265,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function resolve() {
|
||||
const path = policy.result(root(), selected(), rootValid())
|
||||
|
||||
if (!path) return
|
||||
props.onSelect(props.multiple ? [path] : path)
|
||||
dialog.close()
|
||||
@@ -230,10 +273,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
onMount(() => {
|
||||
const closeSuggestions = (event: PointerEvent) => {
|
||||
if (pathArea?.contains(event.target as Node)) return
|
||||
if (event.target instanceof Node && pathArea?.contains(event.target)) return
|
||||
setSuggestionsOpen(false)
|
||||
setActiveSuggestion(-1)
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", closeSuggestions)
|
||||
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
||||
tree = new FileTree({
|
||||
@@ -266,6 +310,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
||||
},
|
||||
})
|
||||
|
||||
if (!container) return
|
||||
tree.render({ containerWrapper: container })
|
||||
tree.getFileTreeContainer()?.classList.add("directory-picker-tree")
|
||||
@@ -273,6 +318,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
@@ -347,13 +393,16 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const scroller = tree
|
||||
?.getFileTreeContainer()
|
||||
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
||||
|
||||
if (!scroller) return
|
||||
|
||||
const next = nextTreeScrollTop(
|
||||
scroller.scrollTop,
|
||||
event.deltaY,
|
||||
scroller.scrollHeight,
|
||||
scroller.clientHeight,
|
||||
)
|
||||
|
||||
if (next === scroller.scrollTop) return
|
||||
event.preventDefault()
|
||||
scroller.scrollTop = next
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy } from "solid-js"
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import type { LocationRef } from "@opencode/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
@@ -24,17 +24,29 @@ export function useDirectoryPicker() {
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let selected = false
|
||||
|
||||
const onSelect = (result: string | string[] | null) => {
|
||||
selected = result !== null
|
||||
input.onSelect(result)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (!selected) input.onSelect(null)
|
||||
}
|
||||
dialog.show(() => <DirectoryPickerDialog {...input} onSelect={onSelect} />, cancel)
|
||||
|
||||
// Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary.
|
||||
dialog.show(
|
||||
() => (
|
||||
<Suspense>
|
||||
<DirectoryPickerDialog {...input} onSelect={onSelect} />
|
||||
</Suspense>
|
||||
),
|
||||
cancel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { discoverEffectSpecifiers } from "@opencode/plugin/runtime-modules.bun"
|
||||
import { Script } from "@opencode/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
@@ -79,28 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeSpecifiers = [
|
||||
"@opencode/plugin",
|
||||
"@opencode/plugin/effect",
|
||||
"@opencode/plugin/effect/plugin",
|
||||
"@opencode/plugin/effect/tool",
|
||||
"@opencode/plugin/promise/plugin",
|
||||
"@opencode/plugin/promise/tool",
|
||||
"@opencode/plugin/rpc",
|
||||
...discoverEffectSpecifiers().map(([specifier]) => specifier),
|
||||
]
|
||||
const pluginRuntimeModulesSource = `const modules = {\n${pluginRuntimeSpecifiers
|
||||
.map((specifier) => ` ${JSON.stringify(specifier)}: () => require(${JSON.stringify(specifier)}),`)
|
||||
.join("\n")}\n}\nexport function pluginRuntimeModules() {\n return modules\n}\n`
|
||||
const pluginRuntimeModulesPlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime-modules",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.bun\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeModulesSource,
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
@@ -147,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [
|
||||
appAssetsPlugin,
|
||||
solidPlugin,
|
||||
parcelWatcherPlugin,
|
||||
opencodePtyPlugin,
|
||||
pluginRuntimeModulesPlugin,
|
||||
simulationGraphPlugin,
|
||||
],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -20,20 +20,30 @@ import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
|
||||
type HandlerContext<Params> = AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }
|
||||
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
|
||||
const catalog = yield* ACPCatalog.make(client)
|
||||
// Requests can dispatch before the service below is built.
|
||||
const ready = yield* Deferred.make<ACPService.Interface>()
|
||||
const ready = yield* Deferred.make<{
|
||||
readonly service: ACPService.Interface
|
||||
readonly connection: ACPConnection.Interface
|
||||
}>()
|
||||
const handle =
|
||||
<Params, A>(
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
|
||||
) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
function* (ctx: HandlerContext<Params>) {
|
||||
const connected = yield* Deferred.await(ready)
|
||||
if (ctx.requestId === undefined) return yield* call(connected.service, ctx)
|
||||
return yield* call(connected.service, ctx).pipe(
|
||||
Effect.provideService(ACPConnection.Responded, connected.connection.responded(ctx.requestId)),
|
||||
)
|
||||
},
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) =>
|
||||
@@ -43,12 +53,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
|
||||
)
|
||||
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
|
||||
if (ctx.requestId === undefined) return run(handler(ctx))
|
||||
return run(
|
||||
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
|
||||
)
|
||||
}
|
||||
return (ctx: HandlerContext<Params>) => run(handler(ctx))
|
||||
}
|
||||
const app = agent({ name: "opencode" })
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
@@ -117,7 +122,10 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
yield* Deferred.succeed(ready, {
|
||||
service: ACPService.make({ client, connection, catalog, sessions, capabilities, turn }),
|
||||
connection,
|
||||
})
|
||||
return acp.agent
|
||||
})
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ReadonlyArray<Model.Info>
|
||||
readonly defaultModel: Model.Ref
|
||||
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
|
||||
@@ -19,24 +18,14 @@ export type Catalog = {
|
||||
readonly commands: ReadonlyArray<Command.Info>
|
||||
}
|
||||
|
||||
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason === "models" ? "No models are available" : "No primary agents are available"
|
||||
}
|
||||
export function findModel(models: ReadonlyArray<Model.Info>, ref: Model.Ref) {
|
||||
return models.find((model) => model.providerID === ref.providerID && model.id === ref.id)
|
||||
}
|
||||
|
||||
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = NotReadyError | LoadError
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, Error>
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, ACPError.CatalogError>
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, ACPError.CatalogError>
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, ACPError.CatalogError>
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
@@ -51,7 +40,7 @@ const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated
|
||||
|
||||
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const scope = yield* Effect.scope
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, ACPError.CatalogError>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Requests queued behind a running load share the next one.
|
||||
@@ -111,7 +100,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return Deferred.await(cached)
|
||||
const loading = Deferred.makeUnsafe<Entry, Error>()
|
||||
const loading = Deferred.makeUnsafe<Entry, ACPError.CatalogError>()
|
||||
entries.set(key, loading)
|
||||
return create(cwd).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
@@ -155,20 +144,17 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
client.command.list({ location }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
|
||||
).pipe(Effect.mapError((cause) => new ACPError.CatalogLoadError({ cause })))
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// The parallel default read can name a model missing from this list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
|
||||
const defaultModel = preferred ? findModel(models, preferred) : models[0]
|
||||
if (!defaultModel) return yield* new ACPError.CatalogNotReadyError({ reason: "models" })
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
// Core lists its resolved default agent first, the same one a new session runs.
|
||||
const defaultAgent = agents[0]
|
||||
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
|
||||
if (!defaultAgent) return yield* new ACPError.CatalogNotReadyError({ reason: "agents" })
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
@@ -181,16 +167,4 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: ReadonlyArray<Model.Info>) {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as ACPCatalog from "./catalog"
|
||||
@@ -1,51 +1,70 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Effect, Order } from "effect"
|
||||
import { builtinCommands, findModel, type Catalog } from "./catalog"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ConfigOptionProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: ReadonlyArray<ConfigOptionModel>
|
||||
}
|
||||
|
||||
type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ModelSelection = {
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
export type Selection = {
|
||||
readonly model?: Model.Ref
|
||||
readonly modeID?: Agent.ID
|
||||
}
|
||||
|
||||
export type Change = { readonly model: Model.Ref } | { readonly modeID: Agent.ID }
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
export function configOptions(catalog: Catalog, selection: Selection): SessionConfigOption[] {
|
||||
const model = currentModel(catalog, selection)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
const variants = findModel(catalog.models, model)?.variants.map((variant) => variant.id) ?? []
|
||||
return [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${model.providerID}/${model.id}`,
|
||||
options: catalog.models
|
||||
.toSorted((a, b) => Order.String(a.providerID, b.providerID) || a.name.localeCompare(b.name))
|
||||
.map((item) => ({ value: `${item.providerID}/${item.id}`, name: `${item.providerID}/${item.name}` })),
|
||||
},
|
||||
...(variants.length > 0
|
||||
? [
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(model.variant, variants),
|
||||
options: [...new Set([...variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
|
||||
value: variant,
|
||||
name: variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
})),
|
||||
} satisfies SessionConfigOption,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: selection.modeID ?? catalog.defaultModeID,
|
||||
options: catalog.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function availableCommands(catalog: Catalog) {
|
||||
@@ -55,111 +74,72 @@ export function availableCommands(catalog: Catalog) {
|
||||
]
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
modes?: readonly ConfigOptionMode[]
|
||||
currentModeId?: string
|
||||
}): SessionConfigOption[] {
|
||||
const variants =
|
||||
input.providers
|
||||
.find((provider) => provider.id === input.currentModel.providerID)
|
||||
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
|
||||
const effort =
|
||||
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
|
||||
return [
|
||||
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
|
||||
...(effort ? [effort] : []),
|
||||
...(input.modes && input.currentModeId
|
||||
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
|
||||
: []),
|
||||
]
|
||||
export const resolveChange = Effect.fnUntraced(function* (
|
||||
catalog: Catalog,
|
||||
selection: Selection,
|
||||
configId: string,
|
||||
value: string,
|
||||
) {
|
||||
const current = currentModel(catalog, selection)
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return { model: yield* requireModel(catalog, value, current) }
|
||||
case "effort": {
|
||||
const model = findModel(catalog.models, current)
|
||||
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort: value })
|
||||
return { model: { ...current, variant: Model.VariantID.make(value) } }
|
||||
}
|
||||
case "mode": {
|
||||
const mode = catalog.modes.find((item) => item.id === value)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: value })
|
||||
return { modeID: mode.id }
|
||||
}
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
export function parseModelSelection(value: string, models: ReadonlyArray<Model.Info>): Model.Ref {
|
||||
const providerID = models
|
||||
.map((model) => model.providerID)
|
||||
.toSorted()
|
||||
.find((id) => value.startsWith(`${id}/`))
|
||||
if (!providerID) {
|
||||
const separator = value.indexOf("/")
|
||||
if (separator === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
|
||||
return { providerID: Provider.ID.make(value.slice(0, separator)), id: Model.ID.make(value.slice(separator + 1)) }
|
||||
}
|
||||
const id = Model.ID.make(value.slice(providerID.length + 1))
|
||||
if (findModel(models, { providerID, id })) return { providerID, id }
|
||||
const separator = id.lastIndexOf("/")
|
||||
const baseID = Model.ID.make(separator === -1 ? id : id.slice(0, separator))
|
||||
const variant = separator === -1 ? undefined : id.slice(separator + 1)
|
||||
const model = findModel(models, { providerID, id: baseID })
|
||||
if (model && variant && model.variants.some((item) => item.id === variant))
|
||||
return { providerID, id: baseID, variant: Model.VariantID.make(variant) }
|
||||
return { providerID, id }
|
||||
}
|
||||
|
||||
export function buildModelSelectOption(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
|
||||
options: input.providers.flatMap((provider) =>
|
||||
provider.models
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEffortSelectOption(input: {
|
||||
variants: readonly string[]
|
||||
currentVariant?: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
|
||||
value: variant,
|
||||
name: formatVariantName(variant),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModeSelectOption(input: {
|
||||
modes: readonly ConfigOptionMode[]
|
||||
currentModeId: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.currentModeId,
|
||||
options: input.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
|
||||
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
|
||||
if (!provider) {
|
||||
const separator = modelId.indexOf("/")
|
||||
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
|
||||
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
|
||||
}
|
||||
const modelID = modelId.slice(provider.id.length + 1)
|
||||
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
|
||||
const separator = modelID.lastIndexOf("/")
|
||||
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
|
||||
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
|
||||
const model = provider.models.find((item) => item.id === baseModelID)
|
||||
if (model && variant && model.variants?.includes(variant)) {
|
||||
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
|
||||
}
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
export function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(value, catalog.models)
|
||||
const model = findModel(catalog.models, selected)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.providerID, modelId: value })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selectedVariant?.id ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
@@ -30,28 +30,8 @@ type Input = {
|
||||
readonly clientSessionID: string
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Outcome = Form.Answer | "cancel" | "settled"
|
||||
|
||||
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("cancel" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("cancel" as const)),
|
||||
Effect.flatMap((outcome) => respond(input, outcome)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const UnshownQuestionMessage =
|
||||
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
|
||||
|
||||
@@ -103,7 +83,7 @@ function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answ
|
||||
)
|
||||
}
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
|
||||
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
|
||||
const response = yield* input.connection.createElicitation({
|
||||
@@ -116,8 +96,7 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return answer(input.form, response) ?? "cancel"
|
||||
})
|
||||
|
||||
function respond(input: Input, outcome: Outcome) {
|
||||
if (outcome === "settled") return Effect.void
|
||||
export function respond(input: Input, outcome: Form.Answer | "cancel") {
|
||||
if (outcome === "cancel") return cancel(input.client, input.form)
|
||||
return input.client.session.form
|
||||
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -56,6 +55,20 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class CatalogNotReadyError extends Schema.TaggedError<CatalogNotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason === "models" ? "No models are available" : "No primary agents are available"
|
||||
}
|
||||
}
|
||||
|
||||
export class CatalogLoadError extends Schema.TaggedError<CatalogLoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type CatalogError = CatalogNotReadyError | CatalogLoadError
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
@@ -70,7 +83,7 @@ export type Error =
|
||||
| ServiceFailureError
|
||||
| ServerUnavailableError
|
||||
|
||||
export type Failure = Error | RequestError | ACPCatalog.Error
|
||||
export type Failure = Error | RequestError | CatalogError
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
|
||||
@@ -5,11 +5,20 @@ import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { applyPatch } from "diff"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
import {
|
||||
absolutePath,
|
||||
canonicalName,
|
||||
filePath,
|
||||
patchHunks,
|
||||
pendingToolCall,
|
||||
stringValue,
|
||||
toLocations,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
|
||||
@@ -24,7 +33,6 @@ type Input = {
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -35,24 +43,7 @@ const options: PermissionOption[] = [
|
||||
|
||||
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("reject" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("reject" as const)),
|
||||
Effect.flatMap((decision) => respond(input, decision)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
@@ -80,8 +71,7 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: Permission.Reply | "settled") {
|
||||
if (decision === "settled") return Effect.void
|
||||
export function respond(input: Input, decision: Permission.Reply) {
|
||||
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
|
||||
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
@@ -95,8 +85,7 @@ const permissionPreviews = Effect.fnUntraced(function* (
|
||||
metadata: ToolInput | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
if (canonicalName(toolName) === "patch") return yield* patchPreviews(input, cwd)
|
||||
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
|
||||
const previews = yield* Effect.forEach(
|
||||
files,
|
||||
@@ -137,7 +126,7 @@ function diff(path: string, oldText: string | null, newText: string) {
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
|
||||
if (previews.length > 1) return `${previews.length} files`
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
switch (canonicalName(toolName)) {
|
||||
case "external_directory":
|
||||
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||
case "webfetch":
|
||||
@@ -151,7 +140,6 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
case "edit":
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return filePath(input) ?? previews[0]?.path
|
||||
default:
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPTranslate } from "./translate"
|
||||
|
||||
export type Prepared = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: Command.Info
|
||||
}
|
||||
|
||||
export const prepare = Effect.fnUntraced(function* (catalog: Catalog, content: readonly ContentBlock[]) {
|
||||
if (content.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(content), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const slash = detectSlashCommand(text)
|
||||
const id = SessionMessage.ID.create()
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id }
|
||||
: { type: "input", id },
|
||||
text,
|
||||
files: visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : [])),
|
||||
synthetic: parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : [])),
|
||||
slash,
|
||||
command: slash ? catalog.commands.find((item) => item.name === slash.name) : undefined,
|
||||
} satisfies Prepared
|
||||
})
|
||||
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
Effect.as(part),
|
||||
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
|
||||
)
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPPrompt from "./prompt"
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { DateTime, Effect, Ref, Schema } from "effect"
|
||||
@@ -36,9 +35,9 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { ACPCapabilities, type Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import { configOptions, resolveChange } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { ACPError } from "./error"
|
||||
@@ -78,42 +77,21 @@ export function make(input: {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
|
||||
return select.pipe(
|
||||
const withReload = <A>(attached: Attached, attempt: Effect.Effect<A, ACPError.Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(attempt))
|
||||
return attempt.pipe(
|
||||
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
|
||||
)
|
||||
}
|
||||
|
||||
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
|
||||
case "effort":
|
||||
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
|
||||
case "mode":
|
||||
return yield* selectMode(attached, value)
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
// Update selection before switching so the echoed event is a no-op.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const mode = catalog.modes.find((item) => item.id === modeID)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
|
||||
yield* input.client.session
|
||||
.switchAgent({ sessionID: attached.id, agent: mode.id })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
const select = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
const change = yield* resolveChange(
|
||||
yield* input.catalog.get(attached.cwd),
|
||||
yield* Ref.get(attached.selection),
|
||||
configId,
|
||||
value,
|
||||
)
|
||||
yield* input.sessions.select(attached, change)
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
|
||||
@@ -245,12 +223,12 @@ export function make(input: {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const value = params.value
|
||||
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
yield* withReload(attached, selectOption(attached, params.configId, value))
|
||||
yield* withReload(attached, select(attached, params.configId, value))
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
setSessionMode: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
yield* withReload(attached, selectMode(attached, params.modeId))
|
||||
yield* withReload(attached, select(attached, "mode", params.modeId))
|
||||
return {}
|
||||
}),
|
||||
prompt: input.turn.prompt,
|
||||
@@ -270,29 +248,4 @@ const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpSe
|
||||
return supported
|
||||
})
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selectedVariant?.id ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort })
|
||||
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
|
||||
})
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -6,7 +6,7 @@ import type { Session } from "@opencode/schema/session"
|
||||
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { availableCommands, configOptions, type Change, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
@@ -28,12 +28,13 @@ export interface Interface {
|
||||
readonly release: (attached: Attached) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
|
||||
readonly select: (attached: Attached, change: Change) => Effect.Effect<void, ACPError.Error>
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
readonly attached: Attached
|
||||
readonly scope: Scope.Closeable
|
||||
readonly selected: Queue.Queue<Selection>
|
||||
readonly selected: Queue.Queue<Change>
|
||||
}
|
||||
|
||||
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
|
||||
@@ -130,7 +131,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
},
|
||||
scope: Scope.forkUnsafe(scope),
|
||||
selected: yield* Queue.unbounded<Selection>(),
|
||||
selected: yield* Queue.unbounded<Change>(),
|
||||
}
|
||||
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
|
||||
const replaced = sessions.get(session.id)
|
||||
@@ -179,6 +180,15 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
// Update selection before switching so the echoed event is a no-op.
|
||||
select: Effect.fnUntraced(function* (attached, change) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, ...change }))
|
||||
yield* (
|
||||
"model" in change
|
||||
? input.client.session.switchModel({ sessionID: attached.id, model: change.model })
|
||||
: input.client.session.switchAgent({ sessionID: attached.id, agent: change.modeID })
|
||||
).pipe(Effect.catch(ACPClient.classify))
|
||||
}),
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
|
||||
@@ -8,14 +8,12 @@ import { Result } from "effect"
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
switch (canonicalName(toolName)) {
|
||||
case "shell":
|
||||
return "execute"
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
case "edit":
|
||||
case "apply_patch":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
@@ -27,7 +25,6 @@ function toToolKind(toolName: string): ToolKind {
|
||||
return "search"
|
||||
case "read":
|
||||
return "read"
|
||||
case "task":
|
||||
case "subagent":
|
||||
return "think"
|
||||
default:
|
||||
@@ -36,8 +33,7 @@ function toToolKind(toolName: string): ToolKind {
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
switch (canonicalName(toolName)) {
|
||||
case "shell":
|
||||
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
|
||||
case "read":
|
||||
@@ -45,7 +41,6 @@ export function toLocations(toolName: string, input: ToolInput, cwd: string): To
|
||||
case "write":
|
||||
return locationFrom(cwd, filePath(input))
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return locationFrom(
|
||||
cwd,
|
||||
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
|
||||
@@ -105,7 +100,7 @@ export function completedToolUpdate(input: {
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const read = canonicalName(input.toolName) === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
const primary =
|
||||
read === undefined
|
||||
@@ -173,8 +168,7 @@ function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
return tool === "bash" || tool === "shell"
|
||||
return canonicalName(toolName) === "shell"
|
||||
}
|
||||
|
||||
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
@@ -184,6 +178,17 @@ function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
)
|
||||
}
|
||||
|
||||
const V1Aliases = new Map([
|
||||
["bash", "shell"],
|
||||
["task", "subagent"],
|
||||
["apply_patch", "patch"],
|
||||
])
|
||||
|
||||
export function canonicalName(toolName: string) {
|
||||
const name = toolName.toLocaleLowerCase()
|
||||
return V1Aliases.get(name) ?? name
|
||||
}
|
||||
|
||||
// Sessions migrated from V1 keep their original `filePath` tool inputs.
|
||||
export function filePath(input: ToolInput) {
|
||||
return stringValue(input.path) ?? stringValue(input.filePath)
|
||||
|
||||
+150
-143
@@ -15,14 +15,12 @@ export type TurnStart = { readonly type: "input" | "compaction"; readonly id: Se
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type Context = {
|
||||
export type TurnContext = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
readonly compaction: boolean
|
||||
/** Background mode follows open children after the turn ends and never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
|
||||
type Tool = {
|
||||
@@ -55,6 +53,7 @@ export type TurnState = {
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
type CreatedEvent = Extract<OpenCodeEvent, { type: "session.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
@@ -65,15 +64,10 @@ export type Output =
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
}
|
||||
| {
|
||||
readonly _tag: "FormAsk"
|
||||
readonly form: FormEvent["data"]["form"]
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
}
|
||||
| { readonly _tag: "FormAsk"; readonly form: FormEvent["data"]["form"]; readonly child?: ACPChild.Session }
|
||||
| { readonly _tag: "AskSettled"; readonly id: string }
|
||||
|
||||
type Step = {
|
||||
export type Folded = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
@@ -89,27 +83,11 @@ export const initial: TurnState = {
|
||||
asks: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
|
||||
if (event.type === "session.created") return childCreated(state, event, ctx)
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
|
||||
if (ctx.mode === "background" && !child) return { state, outputs: [] }
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
const sessionID = sessionIDFromEvent(event)
|
||||
const child = sessionID ? state.children.get(sessionID) : undefined
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
|
||||
const tool = event.data.source?.id
|
||||
@@ -123,14 +101,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
|
||||
outputs: [
|
||||
{
|
||||
_tag: "FormAsk",
|
||||
form: event.data.form,
|
||||
child,
|
||||
toolCallSent: ctx.mode === "turn" && (!child || !ctx.childUpdates),
|
||||
},
|
||||
],
|
||||
outputs: [{ _tag: "FormAsk", form: event.data.form, child }],
|
||||
}
|
||||
}
|
||||
const settledID =
|
||||
@@ -144,22 +115,148 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
asks.delete(settledID)
|
||||
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (!sessionID || (sessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
|
||||
return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
return child ? childEvent(state, event, ctx, child) : rootEvent(state, event, ctx)
|
||||
}
|
||||
|
||||
export function fromTrackedChild(state: TurnState, event: OpenCodeEvent) {
|
||||
const sessionID = event.type === "session.created" ? event.data.parentID : sessionIDFromEvent(event)
|
||||
return sessionID !== undefined && state.children.has(sessionID)
|
||||
}
|
||||
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
// Child compactions are left to the background consumer.
|
||||
export function abandon(state: TurnState, ctx: TurnContext): Folded {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
function childCreated(state: TurnState, event: CreatedEvent, ctx: TurnContext): Folded {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && parentID !== ctx.sessionID) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
|
||||
function rootEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
|
||||
switch (event.type) {
|
||||
case "session.step.started":
|
||||
return sessionEvent({ ...state, stepError: undefined }, event, ctx, undefined)
|
||||
case "session.step.ended":
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
return { state, outputs: [], terminal: "succeeded" }
|
||||
case "session.execution.interrupted":
|
||||
return { state, outputs: [], terminal: "interrupted" }
|
||||
case "session.execution.failed":
|
||||
return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
default:
|
||||
return sessionEvent(state, event, ctx, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function childEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext, child: ACPChild.Session): Folded {
|
||||
switch (event.type) {
|
||||
case "session.execution.started":
|
||||
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
|
||||
case "session.step.started": {
|
||||
const next = child ? state : { ...state, stepError: undefined }
|
||||
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
|
||||
return { state, outputs: childStatus(ctx, child, { type: "status", status: "running" }) }
|
||||
case "session.execution.succeeded":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" })
|
||||
case "session.execution.interrupted":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" })
|
||||
case "session.execution.failed":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error })
|
||||
default:
|
||||
return sessionEvent(state, event, ctx, child)
|
||||
}
|
||||
}
|
||||
|
||||
function sessionEvent(
|
||||
state: TurnState,
|
||||
event: OpenCodeEvent,
|
||||
ctx: TurnContext,
|
||||
child: ACPChild.Session | undefined,
|
||||
): Folded {
|
||||
const sessionID = child?.id ?? ctx.sessionID
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
switch (event.type) {
|
||||
case "session.step.started":
|
||||
if (!state.retries.has(sessionID)) return { state, outputs: [] }
|
||||
return {
|
||||
state: { ...next, retries: without(state.retries, eventSessionID) },
|
||||
state: { ...state, retries: without(state.retries, sessionID) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
|
||||
}
|
||||
}
|
||||
case "session.retry.scheduled": {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
@@ -167,7 +264,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
error: event.data.error,
|
||||
}
|
||||
return {
|
||||
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
|
||||
state: { ...state, retries: new Map(state.retries).set(sessionID, retry) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
|
||||
}
|
||||
}
|
||||
@@ -183,7 +280,7 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
}
|
||||
case "session.compaction.delta": {
|
||||
const update = ACPCompaction.chunk(
|
||||
state.compactions.get(eventSessionID),
|
||||
state.compactions.get(sessionID),
|
||||
event.data.text,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
@@ -299,121 +396,31 @@ export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.step.ended":
|
||||
if (child) return { state, outputs: [] }
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
if (child) return { state, outputs: [] }
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
if (!child) return { state, outputs: [], terminal: "succeeded" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
|
||||
case "session.execution.interrupted":
|
||||
if (!child) return { state, outputs: [], terminal: "interrupted" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
|
||||
case "session.execution.failed":
|
||||
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
|
||||
default:
|
||||
return { state, outputs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
// Child compactions are left to the background consumer.
|
||||
export function abandon(state: TurnState, ctx: Context): Step {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
function newTool(sessionID: string, id: string, name = "tool"): Tool {
|
||||
return { sessionID, id, name, input: {}, metadata: {} }
|
||||
}
|
||||
|
||||
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
function route(ctx: TurnContext, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return [{ _tag: "SessionUpdate", update }]
|
||||
const projected = ACPChild.project(update, child)
|
||||
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
|
||||
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
|
||||
return [{ _tag: "SessionUpdate", update: projected }]
|
||||
}
|
||||
|
||||
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
function childStatus(ctx: TurnContext, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
if (!ctx.childUpdates) return []
|
||||
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
|
||||
}
|
||||
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ACPChild.Session,
|
||||
status: ACPChild.Event,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
function childEnded(state: TurnState, ctx: TurnContext, child: ACPChild.Session, status: ACPChild.Event): Folded {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
openChildren.delete(child.id)
|
||||
return {
|
||||
state: { ...state, openChildren },
|
||||
outputs: childStatus(ctx, child, status),
|
||||
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
|
||||
}
|
||||
return { state: { ...state, openChildren }, outputs: childStatus(ctx, child, status) }
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
|
||||
|
||||
+149
-189
@@ -1,7 +1,5 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
Cause,
|
||||
@@ -18,18 +16,16 @@ import {
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPrompt } from "./prompt"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
@@ -45,26 +41,21 @@ export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/c
|
||||
defaultValue: () => "5 seconds",
|
||||
})
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: Command.Info
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
|
||||
type Subscription = {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
|
||||
/** Asks run serially off the event stream. */
|
||||
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
readonly askQueue: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
readonly settled: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
type Turn = {
|
||||
readonly ctx: ACPTranslate.TurnContext
|
||||
readonly state: Ref.Ref<ACPTranslate.TurnState>
|
||||
readonly subscription: Subscription
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
readonly background: boolean
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
@@ -76,7 +67,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
|
||||
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
const open = Effect.fnUntraced(function* (ctx: ACPTranslate.TurnContext, state: Ref.Ref<ACPTranslate.TurnState>) {
|
||||
// Parented to the service scope; the session scope may already be closed.
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
@@ -84,19 +75,18 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
events: yield* input.client.event
|
||||
.subscribe()
|
||||
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
askQueue: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
settled: new Map(),
|
||||
}
|
||||
yield* Queue.take(subscription.asks).pipe(
|
||||
yield* Queue.take(subscription.askQueue).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP ask reply failed", cause),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkIn(subscriptionScope),
|
||||
)
|
||||
return subscription
|
||||
return { ctx, state, subscription, cancelled: yield* Deferred.make<void>(), background: false } satisfies Turn
|
||||
})
|
||||
|
||||
const take = (subscription: Subscription) =>
|
||||
@@ -108,35 +98,44 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
|
||||
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Queue.offer(subscription.askQueue, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const reply = (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
ask: PermissionAsk,
|
||||
settled: Deferred.Deferred<void>,
|
||||
const queueAsk = <A>(
|
||||
turn: Turn,
|
||||
id: string,
|
||||
ask: Effect.Effect<A, unknown>,
|
||||
fallback: A,
|
||||
respond: (outcome: A) => Effect.Effect<void, ACPError.Error | RequestError>,
|
||||
) =>
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: ask.event,
|
||||
sessionID: ask.event.data.sessionID,
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
child: ask.child,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
turn.subscription.settled.set(id, settled)
|
||||
yield* Queue.offer(
|
||||
turn.subscription.askQueue,
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
Deferred.await(turn.cancelled).pipe(
|
||||
Effect.as(fallback),
|
||||
Effect.raceFirst(Deferred.await(settled).pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed(fallback)),
|
||||
Effect.flatMap((outcome) => (outcome === "settled" ? Effect.void : respond(outcome))),
|
||||
),
|
||||
).pipe(Effect.withSpan("cli.acp.turn.ask")),
|
||||
)
|
||||
})
|
||||
|
||||
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
|
||||
const interpret = (turn: Turn, output: ACPTranslate.Output) => {
|
||||
switch (output._tag) {
|
||||
case "SessionUpdate":
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
if (turn.background) return Effect.void
|
||||
return input.connection.sessionUpdate({ sessionId: turn.ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
@@ -147,65 +146,79 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
case "PermissionAsk":
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.event.data.id, settled)
|
||||
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
|
||||
})
|
||||
case "PermissionAsk": {
|
||||
const permission = {
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: output.event,
|
||||
sessionID: output.event.data.sessionID,
|
||||
clientSessionID: turn.ctx.sessionID,
|
||||
cwd: turn.ctx.cwd,
|
||||
tool: output.tool,
|
||||
child: output.child,
|
||||
}
|
||||
return queueAsk(turn, output.event.data.id, ACPPermission.ask(permission), "reject", (decision) =>
|
||||
ACPPermission.respond(permission, decision),
|
||||
)
|
||||
}
|
||||
case "FormAsk":
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
subscription.asks,
|
||||
ACPElicitation.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
form: output.form,
|
||||
requestedSchema,
|
||||
clientSessionID: ctx.sessionID,
|
||||
child: output.child,
|
||||
toolCallSent: output.toolCallSent,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
),
|
||||
const elicitation = {
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
form: output.form,
|
||||
requestedSchema,
|
||||
clientSessionID: turn.ctx.sessionID,
|
||||
child: output.child,
|
||||
toolCallSent: !turn.background && (!output.child || !turn.ctx.childUpdates),
|
||||
}
|
||||
yield* queueAsk(turn, output.form.id, ACPElicitation.ask(elicitation), "cancel", (outcome) =>
|
||||
ACPElicitation.respond(elicitation, outcome),
|
||||
)
|
||||
})
|
||||
case "AskSettled":
|
||||
return Effect.suspend(() => {
|
||||
const settled = subscription.settled.get(output.id)
|
||||
subscription.settled.delete(output.id)
|
||||
const settled = turn.subscription.settled.get(output.id)
|
||||
turn.subscription.settled.delete(output.id)
|
||||
return settled ? Deferred.succeed(settled, undefined) : Effect.void
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const consume = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) {
|
||||
const advance = Effect.fnUntraced(function* (turn: Turn) {
|
||||
const event = yield* take(turn.subscription)
|
||||
const folded = yield* Ref.modify(turn.state, (current) => {
|
||||
const next =
|
||||
turn.background && !ACPTranslate.fromTrackedChild(current, event)
|
||||
? { state: current, outputs: [] }
|
||||
: ACPTranslate.fold(current, event, turn.ctx)
|
||||
return [next, next.state]
|
||||
})
|
||||
yield* Effect.forEach(folded.outputs, (output) => interpret(turn, output), { discard: true })
|
||||
return folded
|
||||
})
|
||||
|
||||
const consume = Effect.fnUntraced(function* (turn: Turn) {
|
||||
while (true) {
|
||||
const event = yield* take(subscription)
|
||||
const next = yield* Ref.modify(state, (current) => {
|
||||
const step = ACPTranslate.step(current, event, ctx)
|
||||
return [step, step.state]
|
||||
})
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* asksSettled(subscription)
|
||||
return next.terminal
|
||||
const folded = yield* advance(turn)
|
||||
if (folded.terminal) {
|
||||
yield* asksSettled(turn.subscription)
|
||||
return folded.terminal
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const followChildren = Effect.fnUntraced(function* (turn: Turn) {
|
||||
while (true) {
|
||||
const folded = yield* advance(turn)
|
||||
if (folded.state.openChildren.size === 0) return yield* asksSettled(turn.subscription)
|
||||
}
|
||||
})
|
||||
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* input.client.session
|
||||
@@ -241,80 +254,64 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
})
|
||||
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
turn: Turn,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* Deferred.succeed(turn.cancelled, undefined)
|
||||
yield* input.client.session
|
||||
.interrupt({ sessionID: ctx.sessionID })
|
||||
.interrupt({ sessionID: turn.ctx.sessionID })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
|
||||
),
|
||||
)
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (!(yield* Ref.get(turn.state)).started) return
|
||||
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(turn.state), turn.ctx)
|
||||
yield* Ref.set(turn.state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(turn, output), { discard: true }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const execute = (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
subscribe(),
|
||||
(subscription) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
|
||||
}).pipe(Effect.scoped),
|
||||
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
|
||||
)
|
||||
const execute = (attached: Attached, prompt: ACPPrompt.Prepared, turn: Turn) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(turn.subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(turn).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(turn, events)))
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const handoff = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
turn: Turn,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
const close = Scope.close(turn.subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
|
||||
const state = yield* Ref.get(turn.state)
|
||||
if (state.openChildren.size === 0) return yield* close
|
||||
// Children outlive a cancelled turn, so their asks still reach the client.
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(close),
|
||||
Effect.withSpan("cli.acp.turn.background"),
|
||||
)
|
||||
const background = followChildren({
|
||||
...turn,
|
||||
state: yield* Ref.make(state),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
background: true,
|
||||
}).pipe(Effect.ignore, Effect.ensuring(close), Effect.withSpan("cli.acp.turn.background"))
|
||||
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
current: ACPTranslate.TurnState,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
const failure = ACPTranslate.failure(current)
|
||||
if (failure) return yield* failure
|
||||
yield* sendUsageUpdate(attached, current)
|
||||
@@ -346,32 +343,31 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
return yield* settle(attached, state, exit)
|
||||
const exit = yield* Effect.acquireUseRelease(
|
||||
open(
|
||||
{
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
},
|
||||
state,
|
||||
),
|
||||
(turn) => execute(attached, prompt, turn),
|
||||
(turn, exit) => handoff(attached, turn, exit),
|
||||
).pipe(Effect.interruptible, Effect.exit)
|
||||
return yield* settle(attached, yield* Ref.get(state), exit)
|
||||
})
|
||||
|
||||
return {
|
||||
prompt: Effect.fnUntraced(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
|
||||
const prompt = yield* ACPPrompt.prepare(catalog, params.prompt)
|
||||
// Synchronous, so concurrent prompts for one session cannot both register.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
@@ -413,40 +409,4 @@ function aborted(signal: AbortSignal) {
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id: messageID }
|
||||
: { type: "input", id: messageID },
|
||||
text,
|
||||
files,
|
||||
synthetic,
|
||||
slash,
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
Effect.as(part),
|
||||
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
|
||||
)
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPTurn from "./turn"
|
||||
@@ -1,13 +1,13 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions } from "@opencode/client/effect/service"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
@@ -64,11 +64,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
const incumbent =
|
||||
const findIncumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
|
||||
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
|
||||
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
|
||||
yield* RetainedImage.retain(global.cache, "service")
|
||||
@@ -88,7 +88,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
const launch = start(
|
||||
{
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
@@ -144,22 +144,29 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
)
|
||||
const server = yield* launch.pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (findIncumbent === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* findIncumbent.pipe(Effect.timeoutOption(deadline - Date.now()))
|
||||
if (Option.isSome(found) && found.value !== undefined) return
|
||||
yield* Effect.sleep("100 millis")
|
||||
if (Date.now() >= deadline) break
|
||||
// Failed binds close their scope; a successful bind may take longer than this window to boot.
|
||||
const server = yield* launch.pipe(Effect.catchIf(addressInUse, () => Effect.void))
|
||||
if (server !== undefined) return server
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
@@ -175,15 +182,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry(Schedule.spaced("100 millis")),
|
||||
Effect.timeoutOption("15 seconds"),
|
||||
)
|
||||
return Option.isSome(found)
|
||||
})
|
||||
|
||||
function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
// qr-scanner decodes in a worker it creates from a blob: URL whenever the browser has no BarcodeDetector (Safari, desktop
|
||||
// Chrome on Windows and Linux). Without worker-src that worker falls under script-src 'self' and never starts.
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:; worker-src 'self' blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import type { Catalog } from "../../src/acp/catalog"
|
||||
import { configOptions, parseModelSelection } from "../../src/acp/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: [
|
||||
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
|
||||
{ id: "claude-haiku", name: "Claude Haiku" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
|
||||
]
|
||||
const model = (providerID: string, id: string, name: string, variants: string[] = []) => ({
|
||||
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
|
||||
name,
|
||||
variants: variants.map((variant) => ({ id: Model.VariantID.make(variant) })),
|
||||
})
|
||||
|
||||
const catalog: Catalog = {
|
||||
models: [
|
||||
model("anthropic", "claude/sonnet-4", "Claude Sonnet 4", ["default", "high", "very-high"]),
|
||||
model("anthropic", "claude-haiku", "Claude Haiku"),
|
||||
model("openai", "gpt-5", "GPT-5", ["minimal", "low"]),
|
||||
model("test", "effort", "Effort", ["low", "default", "high"]),
|
||||
],
|
||||
defaultModel: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") },
|
||||
modes: [{ id: Agent.ID.make("build"), name: "Build" }],
|
||||
defaultModeID: Agent.ID.make("build"),
|
||||
commands: [],
|
||||
}
|
||||
|
||||
const effortOption = (providerID: string, id: string) =>
|
||||
configOptions(catalog, {
|
||||
model: {
|
||||
providerID: Provider.ID.make(providerID),
|
||||
id: Model.ID.make(id),
|
||||
variant: Model.VariantID.make("missing"),
|
||||
},
|
||||
}).find((option) => option.id === "effort")
|
||||
|
||||
describe("acp config options", () => {
|
||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
||||
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||
expect(effortOption("test", "effort")).toEqual({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
@@ -28,21 +48,29 @@ describe("acp config options", () => {
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
})
|
||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
|
||||
"minimal",
|
||||
)
|
||||
expect(effortOption("openai", "gpt-5")?.currentValue).toBe("minimal")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
|
||||
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
|
||||
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
|
||||
["openai/gpt-5", { providerID: Provider.ID.openai, id: Model.ID.make("gpt-5") }],
|
||||
[
|
||||
"openai/gpt-5/low",
|
||||
{ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5"), variant: Model.VariantID.make("low") },
|
||||
],
|
||||
["anthropic/claude/sonnet-4", { providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4") }],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/high",
|
||||
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
|
||||
{
|
||||
providerID: Provider.ID.anthropic,
|
||||
id: Model.ID.make("claude/sonnet-4"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/missing",
|
||||
{ providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4/missing") },
|
||||
],
|
||||
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
|
||||
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
|
||||
expect(parseModelSelection(value, providers)).toEqual(expected)
|
||||
expect(parseModelSelection(value, catalog.models)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
reasoningDelta,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
@@ -26,13 +25,12 @@ import {
|
||||
} from "./wire-fixture"
|
||||
|
||||
const root = Session.ID.make("ses_root")
|
||||
const ctx: ACPTranslate.Context = {
|
||||
const ctx: ACPTranslate.TurnContext = {
|
||||
sessionID: root,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: SessionMessage.ID.make("msg_input") },
|
||||
childUpdates: false,
|
||||
compaction: false,
|
||||
mode: "turn",
|
||||
}
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
@@ -71,7 +69,7 @@ const compactionMessage = (input: Record<string, unknown>) => ({
|
||||
|
||||
type Row = {
|
||||
readonly name: string
|
||||
readonly ctx?: Partial<ACPTranslate.Context>
|
||||
readonly ctx?: Partial<ACPTranslate.TurnContext>
|
||||
readonly events?: OpenCodeEventEncoded[]
|
||||
readonly messages?: unknown[]
|
||||
readonly expected: object
|
||||
@@ -601,23 +599,6 @@ describe("acp turn translation", () => {
|
||||
test.each(rows)("$name", (row) => {
|
||||
expect(translate(row)).toMatchObject(row.expected)
|
||||
})
|
||||
|
||||
test("ends a background consumer when its last open child settles, without session updates", () => {
|
||||
const turn = run(live(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root)))
|
||||
const background = { ...ctx, mode: "background" as const }
|
||||
const first = run(
|
||||
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
|
||||
background,
|
||||
turn.state,
|
||||
)
|
||||
const last = run([childCreated("ses_later", root, "Later"), interrupted("ses_b")], background, first.state)
|
||||
|
||||
expect(turn.terminal).toBe("succeeded")
|
||||
expect(first.outputs).toEqual([])
|
||||
expect(first.terminal).toBeUndefined()
|
||||
expect(last.state.children.has("ses_later")).toBe(false)
|
||||
expect(last.terminal).toBe("interrupted")
|
||||
})
|
||||
})
|
||||
|
||||
function translate(row: Row) {
|
||||
@@ -646,21 +627,21 @@ function translate(row: Row) {
|
||||
}
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx) {
|
||||
return events.reduce<{
|
||||
state: ACPTranslate.TurnState
|
||||
outputs: ACPTranslate.Output[]
|
||||
terminal?: ACPTranslate.Terminal
|
||||
}>(
|
||||
(acc, event, index) => {
|
||||
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
const next = ACPTranslate.fold(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
...(next.terminal ? { terminal: next.terminal } : {}),
|
||||
}
|
||||
},
|
||||
{ state, outputs: [] },
|
||||
{ state: ACPTranslate.initial, outputs: [] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
permissionAsked,
|
||||
startSession,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
@@ -58,6 +59,8 @@ describe("acp turn events over the wire", () => {
|
||||
{ childSessionId: "ses_background", type: "status", status: "created" },
|
||||
{ childSessionId: "ses_child", type: "status", status: "completed" },
|
||||
{ childSessionId: "ses_background", type: "status", status: "running" },
|
||||
{ childSessionId: "ses_background", type: "update", update: { sessionUpdate: "agent_message_chunk" } },
|
||||
{ childSessionId: "ses_background", type: "status", status: "completed" },
|
||||
],
|
||||
},
|
||||
])(
|
||||
@@ -66,35 +69,43 @@ describe("acp turn events over the wire", () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: row.childSessionUpdates },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
toolStarted("ses_grandchild", "call_read", "read"),
|
||||
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
permissionAsked("ses_grandchild", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_read" },
|
||||
}),
|
||||
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
|
||||
succeeded("ses_grandchild"),
|
||||
childCreated("ses_background", sessionID, "Research"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
acp.server.prompts.length > 1
|
||||
? turn(sessionID, id)
|
||||
: turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
toolStarted("ses_grandchild", "call_read", "read"),
|
||||
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
permissionAsked("ses_grandchild", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_read" },
|
||||
}),
|
||||
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
|
||||
succeeded("ses_grandchild"),
|
||||
childCreated("ses_background", sessionID, "Research"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
const responded = turnUpdates(acp.updates).length
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
textDelta("ses_background", "msg_background", "late"),
|
||||
succeeded("ses_background"),
|
||||
permissionAsked("ses_background", "perm_after_end", { action: "read" }),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 2, "child permission replies")
|
||||
expect(turnUpdates(acp.updates).slice(responded)).toEqual([])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update)).toMatchObject(row.parent)
|
||||
expect(acp.childUpdates).toMatchObject(row.child)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -403,6 +403,77 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("the original managed service contender binds when the occupied port is released", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-retry-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
const requests: string[] = []
|
||||
using listener = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (requests.length === 2) recognizing.resolve()
|
||||
return Response.json({ unrelated: true })
|
||||
},
|
||||
})
|
||||
const port = listener.port
|
||||
if (port === undefined) throw new Error("Server did not bind a port")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "service-local.json"), JSON.stringify({ port }))
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id: "stale",
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: 2_147_483_647,
|
||||
password: "stale",
|
||||
}),
|
||||
)
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: isolatedEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
const stderr = new Response(contender.stderr).text()
|
||||
try {
|
||||
// The first probe is preflight; the second only happens after start() fails to bind.
|
||||
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||
expect(requests).toEqual(["/api/info", "/api/info"])
|
||||
await listener.stop(true)
|
||||
|
||||
const info = await Promise.race([
|
||||
waitForInfo(registration, (info) => info.pid === contender.pid),
|
||||
contender.exited.then(() => undefined),
|
||||
])
|
||||
expect(info?.pid, contender.exitCode === null ? undefined : await stderr).toBe(contender.pid)
|
||||
const endpoint = await Effect.runPromise(
|
||||
Service.discover({ file: registration }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry({ times: 400, schedule: Schedule.spaced("50 millis") }),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(new URL(endpoint.url).port).toBe(String(port))
|
||||
expect(
|
||||
await fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }).then((response) =>
|
||||
response.json(),
|
||||
),
|
||||
).toMatchObject({ pid: contender.pid, version: OPENCODE_VERSION, urls: [endpoint.url] })
|
||||
expect(contender.exitCode).toBe(null)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
expect(await waitForExit(contender)).toBe(true)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await expectPortAvailable(port)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -171,6 +171,7 @@ describe("web UI", () => {
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("worker-src 'self' blob:")
|
||||
|
||||
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
|
||||
expect(dotted.status).toBe(200)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = import("../service.js").Info
|
||||
|
||||
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined) return undefined
|
||||
if (!found.compatible) return undefined
|
||||
if (info === undefined) return undefined
|
||||
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
|
||||
if (!found?.compatible) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
@@ -58,6 +58,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -89,6 +90,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -117,19 +125,27 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
yield* announce("missing")
|
||||
contenders.add(yield* spawnContender)
|
||||
lastSpawn = Date.now()
|
||||
@@ -145,7 +161,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
@@ -157,23 +173,10 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
|
||||
options.pty === "handoff" && info !== undefined
|
||||
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
|
||||
: PtyHandoff.clear(options.file ?? fallback()),
|
||||
).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)),
|
||||
)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
).pipe(Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)))
|
||||
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||
}
|
||||
|
||||
/** Schema for the local service registration file. */
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -184,13 +187,6 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeInfo = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}),
|
||||
)
|
||||
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
@@ -200,77 +196,10 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const body = result.value.body
|
||||
const serverInfo = decodeInfo(body)
|
||||
if (Option.isSome(serverInfo)) {
|
||||
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, timeout)) }
|
||||
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
@@ -289,18 +218,14 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
// The registration can disappear or change hands before this process exits. Only the PID we
|
||||
// signalled can tell us whether it has stopped, so escalate based on that process.
|
||||
if (Option.isNone(done)) {
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
@@ -38,6 +38,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -56,7 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
@@ -68,6 +69,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -95,19 +103,27 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
@@ -129,19 +145,7 @@ export async function stop(options: StopOptions = {}) {
|
||||
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
|
||||
: PtyHandoff.clear(options.file ?? fallback())
|
||||
).catch((cause: unknown) => console.warn("Failed to prepare persistent terminals for replacement", cause))
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
if (info !== undefined) await terminate(info, options, ensureTiming(options))
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
@@ -154,73 +158,6 @@ async function read(file?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.value.body)
|
||||
if (serverInfo !== undefined) {
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
|
||||
async function registered(file?: string, timeout?: number) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
@@ -250,17 +187,13 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
// The registration can disappear or change hands before this process exits. Only the PID we
|
||||
// signalled can tell us whether it has stopped, so escalate based on that process.
|
||||
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Endpoint, Info } from "./service.js"
|
||||
import { defaultEnsureTiming } from "./service-timing.js"
|
||||
|
||||
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
|
||||
// Promise client never loads the Effect runtime.
|
||||
|
||||
export type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/** The default registration file. */
|
||||
export function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Whether two registrations describe the same service instance. */
|
||||
export function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
/** Ask a registered owner for its health and classify the answer. */
|
||||
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.catch(() => undefined)
|
||||
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready",
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.body)
|
||||
if (serverInfo === undefined) return { service: undefined, timedOut: false }
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { mkdtemp, rename, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { waitForExit } from "./service-timing"
|
||||
@@ -8,6 +8,16 @@ export async function serviceFixture() {
|
||||
const registration = join(directory, "service.json")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const pids = new Set<number>()
|
||||
const starts = async () =>
|
||||
(
|
||||
await Bun.file(registration + ".starts")
|
||||
.text()
|
||||
.catch(() => "")
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
const command = (mode: string, ...args: string[]) => [
|
||||
process.execPath,
|
||||
join(import.meta.dir, "service.ts"),
|
||||
@@ -20,11 +30,36 @@ export async function serviceFixture() {
|
||||
directory,
|
||||
registration,
|
||||
command,
|
||||
starts,
|
||||
async waitForStarts(count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const result = await starts()
|
||||
if (result.length >= count) return result
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} contenders`)
|
||||
},
|
||||
async release(pid: number, action: "fail" | "hang" | "ready") {
|
||||
const file = registration + `.release-${pid}`
|
||||
await Bun.write(file + ".tmp", action)
|
||||
await rename(file + ".tmp", file)
|
||||
if (action === "fail") await waitForExit(pid)
|
||||
},
|
||||
spawn(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
},
|
||||
// The service's parent execs `sleep`, which never reaps it, so SIGKILL leaves a zombie that
|
||||
// still answers `kill(pid, 0)`. Terminating the returned parent lets init reap the service.
|
||||
spawnUnreaped(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(["sh", "-c", '"$@" & exec sleep 60', "sh", ...command(mode, ...args)], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
},
|
||||
// Service.ensure detaches contenders; track the elected process before asserting.
|
||||
track(pid: number) {
|
||||
pids.add(pid)
|
||||
@@ -37,6 +72,8 @@ export async function serviceFixture() {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
// Include detached contenders that never registered, even when an assertion fails.
|
||||
for (const pid of await starts()) pids.add(pid)
|
||||
await Promise.all([
|
||||
...processes.map(async (subprocess) => {
|
||||
subprocess.kill("SIGTERM")
|
||||
@@ -54,3 +91,8 @@ export async function serviceFixture() {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPortAvailable(url: string) {
|
||||
const server = Bun.serve({ port: Number(new URL(url).port), fetch: () => new Response() })
|
||||
await server.stop(true)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFile, rename, writeFile } from "node:fs/promises"
|
||||
import { appendFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
|
||||
const [registration, mode, delay] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
@@ -17,6 +17,18 @@ if (mode === "environment") {
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
let controlled = ""
|
||||
if (mode === "controlled") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const release = registration + `.release-${process.pid}`
|
||||
while (!(await Bun.file(release).exists())) await Bun.sleep(5)
|
||||
controlled = await Bun.file(release).text()
|
||||
if (controlled === "fail") {
|
||||
process.stderr.write("actionable startup failure: storage initialization denied\n")
|
||||
process.exit(23)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
||||
@@ -63,7 +75,7 @@ const server = Bun.serve({
|
||||
return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging") {
|
||||
if (mode === "hanging" || controlled === "hang") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
@@ -91,6 +103,12 @@ const server = Bun.serve({
|
||||
},
|
||||
})
|
||||
|
||||
// Install handlers before publishing: a test may signal as soon as the registration appears.
|
||||
if (controlled !== "hang") {
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
@@ -106,8 +124,12 @@ await rename(registration + ".tmp", registration)
|
||||
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
// A lingering server unregisters on SIGTERM but keeps running, and holds its port, until killed.
|
||||
if (mode === "lingering") {
|
||||
await rm(registration, { force: true })
|
||||
await writeFile(registration + ".unregistered", "")
|
||||
await Bun.sleep(Number(delay))
|
||||
}
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Service, type EnsureReason } from "../src/promise/service"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const stop = accelerate(Service.stop)
|
||||
|
||||
test("discovers a registered service", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
@@ -111,6 +112,76 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
@@ -171,3 +242,66 @@ test("signals the registered service process", async () => {
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("stop escalates when the registration disappears before the process exits", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "5000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await existing.exited
|
||||
}, 15_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"stop fails when the process survives SIGKILL",
|
||||
async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawnUnreaped("lingering", "60000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await expect(stop({ file: registration })).rejects.toThrow(`Server process ${original.pid} is still running`)
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("stop waits for the original process while preserving a newly registered successor", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "15000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
// Default timing keeps the grace period open long enough for the successor to register first.
|
||||
const stopping = Service.stop({ file: registration })
|
||||
|
||||
try {
|
||||
await fixture.waitForFile(registration + ".unregistered")
|
||||
const successor = fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
const replacement = await Bun.file(registration).json()
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(replacement.pid).toBe(successor.pid)
|
||||
|
||||
await stopping
|
||||
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).json()).toEqual(replacement)
|
||||
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
|
||||
pid: successor.pid,
|
||||
})
|
||||
expect(successor.exitCode).toBe(null)
|
||||
} finally {
|
||||
existing.kill("SIGKILL")
|
||||
await stopping
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -3,10 +3,11 @@ import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { Service, type EnsureReason } from "../src/effect/service"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const stop = accelerate(Service.stop)
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
@@ -161,6 +162,67 @@ test("signals an unresponsive registered service process", async () => {
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("stop escalates when the registration disappears before the process exits", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "5000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await run(stop({ file: registration }))
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await existing.exited
|
||||
}, 15_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"stop fails when the process survives SIGKILL",
|
||||
async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawnUnreaped("lingering", "60000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
await expect(run(stop({ file: registration }))).rejects.toThrow(`Server process ${original.pid} is still running`)
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("stop waits for the original process while preserving a newly registered successor", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("lingering", "15000")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
// Default timing keeps the grace period open long enough for the successor to register first.
|
||||
const stopping = run(Service.stop({ file: registration }))
|
||||
|
||||
try {
|
||||
await fixture.waitForFile(registration + ".unregistered")
|
||||
const successor = fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
const replacement = await Bun.file(registration).json()
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(replacement.pid).toBe(successor.pid)
|
||||
|
||||
await stopping
|
||||
|
||||
expect(() => process.kill(original.pid, 0)).toThrow()
|
||||
await expectPortAvailable(original.url)
|
||||
expect(await Bun.file(registration).json()).toEqual(replacement)
|
||||
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
|
||||
expect(successor.exitCode).toBe(null)
|
||||
} finally {
|
||||
existing.kill("SIGKILL")
|
||||
await stopping
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
@@ -228,6 +290,76 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -134,7 +134,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -70,6 +70,7 @@ const PACKAGES: Readonly<Record<string, string>> = {
|
||||
"@ai-sdk/xai": "@opencode/ai/providers/xai",
|
||||
"@openrouter/ai-sdk-provider": "@opencode/ai/providers/openrouter",
|
||||
"ai-gateway-provider": "@opencode/ai/providers/cloudflare-ai-gateway",
|
||||
"venice-ai-sdk-provider": "@opencode/ai/providers/venice",
|
||||
}
|
||||
|
||||
const protocols = (name: string) => ({
|
||||
|
||||
@@ -61,17 +61,9 @@ const layer = Layer.effect(
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}).pipe(
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
|
||||
@@ -128,17 +128,6 @@ const load = Effect.fn("PluginModule.load")(function* (
|
||||
target: operation.target,
|
||||
...(installed?.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) =>
|
||||
Effect.suspend(() => {
|
||||
const effect = plugin.effect({ ...host, options: operation.options })
|
||||
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
|
||||
return Effect.die(
|
||||
new Error(
|
||||
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return effect
|
||||
}),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Generation
|
||||
})
|
||||
@@ -26,7 +26,6 @@ import { PoePlugin } from "./provider/poe.js"
|
||||
import { PromptCacheKeyPlugin } from "./provider/prompt-cache-key.js"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
import { XAIPlugin } from "./provider/xai.js"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux.js"
|
||||
@@ -60,7 +59,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
PromptCacheKeyPlugin,
|
||||
SapAICorePlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
XAIPlugin,
|
||||
ZenmuxPlugin,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const VenicePlugin = createProviderPlugin({
|
||||
id: "opencode.provider.venice",
|
||||
package: "venice-ai-sdk-provider",
|
||||
load: async (options) => {
|
||||
const { createVenice } = await import("venice-ai-sdk-provider")
|
||||
return createVenice(options)
|
||||
},
|
||||
})
|
||||
@@ -97,6 +97,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode/ai/providers/openai-compatible", () => import("@opencode/ai/providers/openai-compatible")],
|
||||
["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")],
|
||||
["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")],
|
||||
["@opencode/ai/providers/venice", () => import("@opencode/ai/providers/venice")],
|
||||
["@opencode/ai/providers/xai", () => import("@opencode/ai/providers/xai")],
|
||||
["@opencode/ai/providers/zai/chat", () => import("@opencode/ai/providers/zai/chat")],
|
||||
["@opencode/ai/providers/zai-coding-plan/chat", () => import("@opencode/ai/providers/zai-coding-plan/chat")],
|
||||
|
||||
@@ -19,9 +19,11 @@ import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { Model } from "../model.js"
|
||||
import { State } from "../state.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
@@ -30,6 +32,7 @@ import { SessionHistory } from "./history.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionRunnerRetry } from "./runner/retry.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import { contentFilterError, toSessionError } from "./to-session-error.js"
|
||||
@@ -181,8 +184,11 @@ const LEGACY_HEADING = "## Additional Context"
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const catalog = yield* Model.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
|
||||
@@ -202,24 +208,33 @@ export const layer = Layer.effect(
|
||||
|
||||
// Only the user compacts when automatic compaction is off, overflow included.
|
||||
if (trigger.reason !== "manual" && !settings.auto) return { status: "skipped" }
|
||||
const ceiling = calculateCeiling(context.model.limit, settings.buffer)
|
||||
if (trigger.reason === "auto" && !due(context, ceiling)) return { status: "skipped" }
|
||||
if (trigger.reason === "auto" && !due(context, calculateCeiling(context.model.limit, settings.buffer)))
|
||||
return { status: "skipped" }
|
||||
const native = context.model.compaction?.type === "native"
|
||||
const agent = native ? undefined : yield* agents.get(Agent.ID.make("compaction"))
|
||||
const model =
|
||||
(agent?.model &&
|
||||
(yield* models
|
||||
.resolve({ ...context.session, model: agent.model }, catalog.available)
|
||||
.pipe(Effect.orElseSucceed(() => undefined)))) ??
|
||||
context.model
|
||||
const ceiling = calculateCeiling(model.limit, settings.buffer)
|
||||
// An unknown window never triggers auto compaction, but the compaction request still needs a size to aim for.
|
||||
const cap = Number.isFinite(ceiling)
|
||||
? ceiling
|
||||
: calculateCeiling({ ...context.model.limit, context: UNKNOWN_WINDOW }, settings.buffer)
|
||||
: calculateCeiling({ ...model.limit, context: UNKNOWN_WINDOW }, settings.buffer)
|
||||
// The provider just rejected this context, so the estimate ran low; the first attempt already aims below it.
|
||||
const budget =
|
||||
trigger.reason === "overflow" ? Math.min(cap, Math.floor(estimateContext(context) * SHRINK_STEPS[0])) : cap
|
||||
|
||||
const compaction =
|
||||
context.model.compaction?.type === "native"
|
||||
? compactNatively(trigger, budget, settings.keep)
|
||||
: summarize(trigger, budget, settings.keep)
|
||||
const selected = model === context.model ? trigger : { ...trigger, context: { ...context, model } }
|
||||
const compaction = native
|
||||
? compactNatively(selected, budget, settings.keep)
|
||||
: summarize(selected, budget, settings.keep)
|
||||
return yield* compaction.pipe(
|
||||
Effect.matchEffect({
|
||||
onSuccess: (result) => publish(trigger, result),
|
||||
onFailure: (failure) => publish(trigger, failure),
|
||||
onSuccess: (result) => publish(selected, result),
|
||||
onFailure: (failure) => publish(selected, failure),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -662,7 +677,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Database.node, llmClient, SessionModelRequest.node],
|
||||
deps: [Agent.node, Bus.node, Database.node, llmClient, Model.node, SessionModelRequest.node, SessionRunnerModel.node],
|
||||
})
|
||||
|
||||
/** History loads from the latest completed compaction, so a previous one is always the first message. */
|
||||
|
||||
@@ -220,6 +220,14 @@ const openaiCompatible: Protocol = (model, support) => {
|
||||
return openaiChat(model, support)
|
||||
}
|
||||
|
||||
const veniceChat: Protocol = (_, support) => {
|
||||
if (support.type === "effort")
|
||||
return efforts(support.values ?? EFFORTS, (effort) => ({ settings: { reasoningEffort: effort } }))
|
||||
if (support.type === "toggle")
|
||||
return toggle({ settings: { reasoning: { enabled: false } } }, { settings: { reasoning: { enabled: true } } })
|
||||
return []
|
||||
}
|
||||
|
||||
const moonshotChat: Protocol = (model, support) => {
|
||||
const id = modelID(model).toLowerCase()
|
||||
const toggleable = id.includes("k2.5") || id.includes("k2-5") || id.includes("k2.6") || id.includes("k2-6")
|
||||
@@ -583,6 +591,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/mistral": openaiChat,
|
||||
"@opencode/ai/providers/moonshot/chat": moonshotChat,
|
||||
"@opencode/ai/providers/togetherai": openaiChat,
|
||||
"@opencode/ai/providers/venice": veniceChat,
|
||||
"@opencode/ai/providers/xai": xaiResponses,
|
||||
"@opencode/ai/providers/zai/chat": zaiChat,
|
||||
"@opencode/ai/providers/zai-coding-plan/chat": zaiChat,
|
||||
@@ -602,7 +611,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/cohere": cohere,
|
||||
"@opencode/ai/providers/openrouter": openrouter,
|
||||
|
||||
[Provider.aisdk("venice-ai-sdk-provider")]: openaiChat,
|
||||
[Provider.aisdk("venice-ai-sdk-provider")]: veniceChat,
|
||||
"@opencode/ai/providers/cloudflare-ai-gateway": cloudflareAIGateway,
|
||||
[Provider.aisdk("@ai-sdk/gateway")]: vercelGateway,
|
||||
[Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore,
|
||||
|
||||
@@ -46,6 +46,7 @@ const it = testEffect(
|
||||
}),
|
||||
),
|
||||
Config.node.replace(config),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/tmp") })),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { expect } from "bun:test"
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Brand, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Rpc } from "@opencode/core/rpc"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { execute } from "@opencode/core/tool/runtime"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
PluginTestLayer,
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
@@ -100,252 +90,3 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugins and their transitive dependencies against the host's Effect instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "plugin")
|
||||
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(path.join(pluginDir, "node_modules"), { recursive: true })
|
||||
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
|
||||
|
||||
const pkgPath = path.join(pluginEffectDir, "package.json")
|
||||
const pkg = { ...(await Bun.file(pkgPath).json()), version: "4.0.0-rc.111" }
|
||||
await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
|
||||
|
||||
// Reproduce the version-skew failure modes on the plugin's own Effect copy so loading it would crash:
|
||||
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
|
||||
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
|
||||
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
|
||||
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
|
||||
const internalEffect = (await readFile(internalEffectPath, "utf8"))
|
||||
.replace(
|
||||
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
|
||||
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
"export const runPromiseWith = context => {",
|
||||
"export const runPromiseWith = context => {\n return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });",
|
||||
)
|
||||
await writeFile(internalEffectPath, internalEffect)
|
||||
|
||||
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
|
||||
await mkdir(depDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(depDir, "package.json"),
|
||||
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(depDir, "index.js"),
|
||||
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
|
||||
import { some as depSome } from "effect/Option"
|
||||
export const depToolInput = DepSchema.Struct({
|
||||
mode: DepSchema.String.pipe(DepSchema.withDecodingDefault(DepEffect.succeed("from-dep"))),
|
||||
})
|
||||
export { DepEffect, DepSchema, depSome }`,
|
||||
)
|
||||
|
||||
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
|
||||
await mkdir(foreignPluginPkgDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
path.join(foreignPluginPkgDir, "effect.js"),
|
||||
"export const Plugin = { define: (p) => p }",
|
||||
)
|
||||
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
|
||||
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
|
||||
import { some as pluginSome } from "effect/Option"
|
||||
import { nominal as pluginNominal } from "effect/Brand"
|
||||
import { DepEffect, DepSchema, depSome, depToolInput } from "transitive-dep"
|
||||
|
||||
export const captured = {
|
||||
PluginEffect,
|
||||
PluginSchema,
|
||||
pluginSome,
|
||||
pluginNominal,
|
||||
DepEffect,
|
||||
DepSchema,
|
||||
depSome,
|
||||
pluginCount: -1,
|
||||
}
|
||||
|
||||
const Contract = Rpc.define({
|
||||
id: "host-effect-rpc",
|
||||
methods: {
|
||||
check: {
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int.pipe(PluginSchema.withDecodingDefault(PluginEffect.succeed(5))),
|
||||
tag: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({
|
||||
value: PluginSchema.String,
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
export default Plugin.define({
|
||||
id: "host-effect-fixture",
|
||||
effect: (ctx) =>
|
||||
PluginEffect.gen(function* () {
|
||||
yield* PluginEffect.log("setup log from plugin")
|
||||
const listed = yield* PluginEffect.promise(() =>
|
||||
PluginEffect.runPromise(ctx.plugin.list().pipe(PluginEffect.orDie)),
|
||||
)
|
||||
captured.pluginCount = listed.data.length
|
||||
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "default_tool",
|
||||
description: "Tool with decoding default from transitive dependency",
|
||||
input: depToolInput,
|
||||
output: PluginSchema.Struct({ mode: PluginSchema.String }),
|
||||
execute: ({ mode }) =>
|
||||
PluginEffect.log("executing default_tool").pipe(
|
||||
PluginEffect.as({ output: { mode }, content: mode }),
|
||||
),
|
||||
})
|
||||
editor.add({
|
||||
name: "check_tool",
|
||||
description: "Tool with Int, Trim, and isPattern checks",
|
||||
input: PluginSchema.Struct({
|
||||
count: PluginSchema.Int,
|
||||
code: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: PluginSchema.Struct({ formatted: PluginSchema.String }),
|
||||
execute: ({ count, code }) =>
|
||||
PluginEffect.succeed({
|
||||
output: { formatted: \`\${code}:\${count}\` },
|
||||
content: \`\${code}:\${count}\`,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.rpc.register(Contract, {
|
||||
check: ({ count, tag }) =>
|
||||
PluginEffect.log("executing rpc check").pipe(
|
||||
PluginEffect.as({ value: \`\${tag}#\${count}\` }),
|
||||
),
|
||||
}).pipe(PluginEffect.orDie)
|
||||
}),
|
||||
})`,
|
||||
)
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
|
||||
|
||||
const imported = (yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))) as {
|
||||
captured: {
|
||||
PluginEffect: unknown
|
||||
PluginSchema: unknown
|
||||
pluginSome: unknown
|
||||
pluginNominal: unknown
|
||||
DepEffect: unknown
|
||||
DepSchema: unknown
|
||||
depSome: unknown
|
||||
pluginCount: number
|
||||
}
|
||||
}
|
||||
expect(imported.captured.PluginEffect).toBe(Effect)
|
||||
expect(imported.captured.PluginSchema).toBe(Schema)
|
||||
expect(imported.captured.pluginSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginNominal).toBe(Brand.nominal)
|
||||
expect(imported.captured.DepEffect).toBe(Effect)
|
||||
expect(imported.captured.DepSchema).toBe(Schema)
|
||||
expect(imported.captured.depSome).toBe(Option.some)
|
||||
expect(imported.captured.pluginCount).toBe(0)
|
||||
|
||||
const registeredTools = yield* tools.list()
|
||||
const defaultTool = registeredTools.find((tool) => tool.id === "default_tool")
|
||||
const checkTool = registeredTools.find((tool) => tool.id === "check_tool")
|
||||
expect(defaultTool).toBeDefined()
|
||||
expect(checkTool).toBeDefined()
|
||||
if (!defaultTool || !checkTool) return
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_host_effect"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_host_effect"),
|
||||
id: Tool.CallID.make("call_host_effect"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
expect(yield* execute(defaultTool, {}, context)).toEqual({
|
||||
output: { mode: "from-dep" },
|
||||
content: [{ type: "text", text: "from-dep" }],
|
||||
})
|
||||
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
|
||||
output: { formatted: "v42:3" },
|
||||
content: [{ type: "text", text: "v42:3" }],
|
||||
})
|
||||
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails activation when a plugin returns an Effect from a bundled copy of effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "bundled-plugin")
|
||||
const bundledEffectDir = path.join(pluginDir, "bundled-effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
await cp(hostEffectDir, bundledEffectDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { Effect as BundledEffect } from "./bundled-effect/dist/index.js"
|
||||
export default {
|
||||
id: "bundled-effect-plugin",
|
||||
effect: () => BundledEffect.void,
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([
|
||||
{
|
||||
id: "bundled-effect-plugin",
|
||||
state: {
|
||||
status: "failed",
|
||||
error: expect.stringContaining(
|
||||
'Plugin "bundled-effect-plugin" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.',
|
||||
),
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -7,7 +7,6 @@ import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { CoherePlugin } from "@opencode/core/plugin/provider/cohere"
|
||||
import { GatewayPlugin } from "@opencode/core/plugin/provider/gateway"
|
||||
import { PerplexityPlugin } from "@opencode/core/plugin/provider/perplexity"
|
||||
import { VenicePlugin } from "@opencode/core/plugin/provider/venice"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -18,7 +17,6 @@ const providers = [
|
||||
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
|
||||
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
|
||||
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
|
||||
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
|
||||
] as const
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode/core/session/sql"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
import { App } from "@opencode/core/app"
|
||||
@@ -87,7 +88,11 @@ const it = testEffect(
|
||||
SessionModelRequest.node,
|
||||
PluginHooks.node,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
llmClient.replace(client),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
@@ -40,7 +41,10 @@ const it = testEffect(
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,17 @@ CLI preparation uses these channel rules:
|
||||
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
|
||||
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
|
||||
|
||||
To run a worktree alongside another dev instance, give it a separate profile and unused server/debug ports:
|
||||
|
||||
```bash
|
||||
OPENCODE_DESKTOP_TEST_ROOT="$HOME/.local/share/opencode/desktop-worktrees/my-worktree" \
|
||||
OPENCODE_DESKTOP_SERVER_PORT=3085 \
|
||||
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT=9223 bun dev
|
||||
```
|
||||
|
||||
The profile isolates desktop state, server data, configuration, and service registration. The renderer dev server
|
||||
automatically selects an available port. Without the server port override, isolated dev servers use port `3084`.
|
||||
|
||||
## Startup benchmark
|
||||
|
||||
`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the
|
||||
@@ -74,4 +85,4 @@ written to `dist/bench-startup`.
|
||||
|
||||
A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the
|
||||
installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a
|
||||
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
|
||||
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -4,20 +4,26 @@ import { Script } from "@opencode/script"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
|
||||
if (!Script.release) {
|
||||
console.log("skipped desktop publication without a release")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const directory = process.env.OPENCODE_DESKTOP_DIST
|
||||
|
||||
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
|
||||
|
||||
const files = (
|
||||
await Array.fromAsync(
|
||||
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
|
||||
)
|
||||
).sort()
|
||||
|
||||
if (!files.length) throw new Error("No desktop release files found")
|
||||
|
||||
const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun })
|
||||
|
||||
const artifact = {
|
||||
channel: Script.channel,
|
||||
name: "desktop",
|
||||
@@ -25,7 +31,9 @@ const artifact = {
|
||||
version: Script.version,
|
||||
metadata: { files: uploaded, ...(await metadata(Script.version, uploaded)) },
|
||||
}
|
||||
|
||||
if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(artifact)}`)
|
||||
|
||||
if (!dryRun) await UpdateArtifact.publish(artifact)
|
||||
|
||||
type DesktopFile = {
|
||||
@@ -37,7 +45,9 @@ type DesktopFile = {
|
||||
|
||||
async function metadata(version: string, files: Record<string, { url: string }>) {
|
||||
const directory = process.env.LATEST_YML_DIR
|
||||
|
||||
if (!directory) throw new Error("LATEST_YML_DIR is required")
|
||||
|
||||
const entries = await Promise.all(
|
||||
[
|
||||
{
|
||||
@@ -64,54 +74,75 @@ async function metadata(version: string, files: Record<string, { url: string }>)
|
||||
await Promise.all(
|
||||
item.sources.map(async ([subdirectory, source]) => {
|
||||
const file = Bun.file(`${directory}/${subdirectory}/${source}`)
|
||||
|
||||
if (!(await file.exists())) return undefined
|
||||
|
||||
return parse(await file.text(), version, files)
|
||||
}),
|
||||
)
|
||||
).filter((manifest) => manifest !== undefined)
|
||||
|
||||
if (manifests.length !== item.sources.length) return undefined
|
||||
|
||||
return [
|
||||
item.name,
|
||||
{ files: manifests.flatMap((manifest) => manifest.files), releaseDate: manifests[0]!.releaseDate },
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
|
||||
if (entries.some((entry) => entry === undefined)) throw new Error("Desktop update metadata is incomplete")
|
||||
const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
|
||||
|
||||
return { manifests }
|
||||
}
|
||||
|
||||
function parse(content: string, version: string, uploaded: Record<string, { url: string }>) {
|
||||
const lines = content.split("\n")
|
||||
|
||||
const found = lines
|
||||
.find((line) => line.startsWith("version:"))
|
||||
?.slice("version:".length)
|
||||
.trim()
|
||||
|
||||
if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
|
||||
|
||||
const releaseDate = lines
|
||||
.find((line) => line.startsWith("releaseDate:"))
|
||||
?.slice("releaseDate:".length)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
|
||||
if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
|
||||
const files: DesktopFile[] = []
|
||||
lines.forEach((line) => {
|
||||
const value = line.trim()
|
||||
|
||||
if (value.startsWith("- url:")) {
|
||||
const name = value.slice("- url:".length).trim()
|
||||
const filename = name.startsWith("http") ? decodeURIComponent(new URL(name).pathname.split("/").pop()!) : name
|
||||
const url = uploaded[filename]?.url
|
||||
|
||||
if (!url) throw new Error(`Desktop update file was not uploaded: ${filename}`)
|
||||
files.push({ url, sha512: "", size: 0 })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const current = files.at(-1)
|
||||
if (!current) return
|
||||
|
||||
// electron-builder repeats the first file's sha512 as a top-level legacy field after the list.
|
||||
if (!current || !line.startsWith(" ")) return
|
||||
|
||||
if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
|
||||
|
||||
if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
|
||||
|
||||
if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
|
||||
})
|
||||
|
||||
if (!files.length || files.some((file) => !file.sha512 || !file.size))
|
||||
throw new Error("Desktop metadata contained an incomplete file")
|
||||
|
||||
return { files, releaseDate }
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user