Compare commits

..
Author SHA1 Message Date
usrnk1 5352e107c0 docs(desktop): capture queue UI before and after 2026-10-05 14:01:02 +02:00
usrnk1 dbd2e1ed79 feat(desktop): allow parallel worktree dev instances 2026-10-05 14:00:39 +02:00
usrnk1 7e1091e3eb feat(desktop): clarify queued and pending steer messages 2026-10-05 14:00:26 +02:00
Victor Navarro 759175abad fix(tui): untangle crossing branches in mermaid flowcharts (#53325) 2026-10-05 13:22:09 +02:00
Shoubhit Dash 88dd4417c7 refactor(acp): simplify the turn fold (#52826) 2026-10-05 15:07:41 +05:30
opencode-agent[bot]andOpeOginni 4fb800734d feat(app): restore the session location missing prompt (#53268)
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
2026-10-05 17:16:22 +08:00
Shoubhit Dash 473e4a960d refactor(acp): simplify config options and catalog (#52825) 2026-10-05 14:43:10 +05:30
Brendan Allan 2dfd08edc9 refactor(app): record /btw chats without waiting for the store to load (#53312) 2026-10-05 08:53:27 +00:00
Luke Parker 45e8c02bcc chore(ci): skip workflows for the release version-sync commit (#53310) 2026-10-05 18:49:34 +10:00
Luke Parker 938baed23b fix(desktop): keep each file's sha512 in update manifests (#53309) 2026-10-05 18:35:00 +10:00
494d70707b feat(app): keep each /btw answer in its own tab until closed (#53270)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-10-05 08:13:34 +00:00
Luke Parker eab83d63fd fix(app): keep the phone titlebar clear of the iOS status bar blur (#53297) 2026-10-05 18:12:38 +10:00
Kit Langton fad352c5d0 refactor(client): share the service health probe between clients (#53237) 2026-10-04 22:58:58 -07:00
Luke Parker e1b94a138e fix(app): keep Working while reads join a read group (#53292) 2026-10-05 05:39:32 +00:00
opencode 9e96aacd64 sync release versions for v2.0.23 2026-10-05 05:24:44 +00:00
Aiden Cline e19263ca87 fix(ai): send effort updates on the Vertex Claude route (#53289) 2026-10-05 00:11:18 -05:00
Luke Parker 63e33bc4dc fix(cli): let the web app start the QR scanner worker (#53278) 2026-10-05 14:53:49 +10:00
Kit Langton 999097bdac fix(client): preserve background service startup failures (#50784) 2026-10-04 21:53:34 -07:00
Kit Langton 515b3022ac fix(cli): retry managed service binds after temporary conflicts (#50782) 2026-10-04 21:33:43 -07:00
Aiden ClineandPadure Sergio cb389aa679 fix(core): honor compaction agent model for summaries (#53276)
Co-authored-by: Padure Sergio <70955746+sergiopadure@users.noreply.github.com>
2026-10-04 23:20:24 -05:00
Aiden Cline 30f4119459 fix(ai): place Anthropic system updates before the next assistant turn (#52568) 2026-10-04 23:17:15 -05:00
opencode-agent[bot] 4d17cf719f chore: update nix node_modules hashes 2026-10-05 04:15:19 +00:00
Aiden Cline 498b1deda3 feat(ai): add native Venice provider (#53271) 2026-10-04 23:06:43 -05:00
Kit Langton 25c54cbdf7 fix(client): wait for service shutdown before restart (#50042) 2026-10-04 20:59:19 -07:00
Luke Parker ae57dd0cf5 fix(app): close the running menu before opening a subagent (#53273) 2026-10-05 13:58:19 +10:00
155 changed files with 4231 additions and 2412 deletions

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

+36 -63
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+35
View File
@@ -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 -1
View File
@@ -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",
+35 -43
View File
@@ -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
})
+13 -9
View File
@@ -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({
+374
View File
@@ -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,
+1
View File
@@ -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"
+62
View File
@@ -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"
+42 -7
View File
@@ -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" })
}),
)
})
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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
}
}
]
}
@@ -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"
}
}
]
}
@@ -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)
}
}),
)
+370
View File
@@ -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)
}
}),
)
+171 -11
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.22",
"version": "2.0.23",
"description": "",
"type": "module",
"exports": {
+3 -2
View File
@@ -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)
+31 -14
View File
@@ -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")
+7
View File
@@ -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)}
+40 -12
View File
@@ -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
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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)
+12 -3
View File
@@ -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.
+24 -7
View File
@@ -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 -1
View File
@@ -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": {
+1 -31
View File
@@ -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,
+18 -10
View File
@@ -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
})
+13 -39
View File
@@ -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"
+114 -134
View File
@@ -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
}
+3 -24
View File
@@ -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 })
+15 -2
View File
@@ -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) {
+15 -27
View File
@@ -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
+61
View File
@@ -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"
+15 -62
View File
@@ -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"
+13 -3
View File
@@ -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
})
+15 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+28 -30
View File
@@ -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}`
}
+3 -1
View File
@@ -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) {
+50 -22
View File
@@ -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)
})
})
+5 -24
View File
@@ -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: [] },
)
}
+28 -17
View File
@@ -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)
+72 -1
View File
@@ -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>()
+1
View File
@@ -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 -1
View File
@@ -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": {
+31 -106
View File
@@ -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)))
}
+25 -92
View File
@@ -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`)
}
+84
View File
@@ -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)
}
+26 -4
View File
@@ -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"))
+135 -1
View File
@@ -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)
+133 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.22",
"version": "2.0.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -2
View File
@@ -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:"
+1
View File
@@ -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) => ({
+3 -11
View File
@@ -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>) =>
+1 -12
View File
@@ -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
})
-2
View File
@@ -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)
},
})
+1
View File
@@ -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")],
+25 -10
View File
@@ -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. */
+10 -1
View File
@@ -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") })),
]),
),
)
+2 -261
View File
@@ -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") })),
],
),
)
+12 -1
View File
@@ -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 -1
View File
@@ -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",
+32 -1
View File
@@ -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