Compare commits

..

37 Commits

Author SHA1 Message Date
Aiden Cline 5d628affa4 refactor(ai): inline Azure URL check 2026-08-05 13:08:18 -05:00
Aiden Cline 31e7e9da4b refactor(ai): clarify Azure endpoint routing 2026-08-04 23:41:39 -05:00
Aiden Cline b18b38868f fix(core): discover legacy Azure API key 2026-08-04 23:36:26 -05:00
Aiden Cline e4811d96f7 Merge remote-tracking branch 'origin/v2' into legacy-provider-aliases
# Conflicts:
#	packages/core/src/plugin/provider.ts
2026-08-04 20:58:13 -05:00
Aiden Cline 330ef108ce fix(core): report missing shell workdir (#40542) 2026-08-04 20:56:02 -05:00
opencode-agent[bot] f9ac6f3171 fix(ai): restore Responses tool input semantics (#40553)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:46:51 -05:00
opencode-agent[bot] 62e5d73d45 fix(ai): classify malformed Responses tool calls (#40549)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:40:36 -05:00
Kit Langton ab20a7b4a3 feat(tui): streamline tab navigation shortcuts (#40551) 2026-08-05 01:36:23 +00:00
opencode-agent[bot] ad9a95f6ee fix(ai): preserve Gemini tool finish semantics (#40546)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:29:06 -05:00
Aiden Cline 4ed343706d fix: retry empty incomplete streams (#40535) 2026-08-04 19:38:56 -05:00
Aiden Cline 81840423d4 refactor(ai): select Azure protocol by package 2026-08-04 19:37:37 -05:00
opencode-agent[bot] 6d2eb2240a fix(app): finish tool call ID rename (#40539)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 19:26:45 -05:00
Dax Raad 53b8111121 refactor: rename tool call ID fields 2026-08-04 17:08:12 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
Aiden Cline 9c896dbfca fix(core): preserve Azure provider behavior 2026-08-04 15:05:25 -05:00
Aiden Cline b9c118e0fe chore(core): drop unreachable AI SDK provider packages (#40504) 2026-08-04 14:37:56 -05:00
opencode-agent[bot] d1c9e39978 fix(tui): update tab titles immediately (#40318)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-04 14:23:29 -04:00
Aiden Cline 7bfa594f29 refactor(core): simplify provider migration 2026-08-04 12:45:46 -05:00
Aiden Cline 2593d1c724 docs(core): simplify migration wording 2026-08-04 12:32:20 -05:00
Aiden Cline f07a9adaac docs(core): clarify v1 provider migration 2026-08-04 12:31:29 -05:00
Aiden Cline 287d54f2f3 fix(core): prefer canonical v1 providers 2026-08-04 12:30:13 -05:00
Aiden Cline 0b1ec457dc fix(tui): show branch beside directory (#40500) 2026-08-04 12:26:18 -05:00
Aiden Cline b29cf137ea fix(core): allow custom legacy provider ids 2026-08-04 12:13:06 -05:00
Aiden Cline 24b725c0d1 fix(core): clarify deprecated providers 2026-08-04 12:03:18 -05:00
Aiden Cline 7548c62088 docs(core): restore model resolution context 2026-08-04 12:02:27 -05:00
Aiden Cline 9bd99b4c91 feat(vcs): publish branch updates (#40371) 2026-08-04 11:52:17 -05:00
Aiden Cline cee144dc9a refactor(core): keep catalog api minimal 2026-08-04 11:49:04 -05:00
Aiden Cline f0ddec2f48 fix(core): migrate legacy provider aliases 2026-08-04 11:43:55 -05:00
Aiden Cline 6388d2d4b4 refactor(core): reuse model unavailable error 2026-08-04 11:25:48 -05:00
Aiden Cline b3449758b6 refactor(core): centralize removed providers 2026-08-04 11:17:40 -05:00
opencode-agent[bot] 94fcb119a3 fix(core): avoid implicit output limits (#40488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 11:09:23 -05:00
Aiden Cline e75020b6d9 fix(core): reject removed providers 2026-08-04 10:44:36 -05:00
Aiden Cline adf1a56337 fix(core): omit legacy provider aliases 2026-08-04 10:26:09 -05:00
Aiden Cline aa820db18d feat(plugin): add session HTTP middleware (#40327) 2026-08-04 09:15:52 -05:00
Aiden Cline 42e8d11552 feat(vcs): expose branch info (#40368) 2026-08-03 23:18:13 -05:00
Aiden Cline 28d784b83a fix(core): execute tools renamed by context hooks (#40359) 2026-08-03 22:57:06 -05:00
Aiden Cline a4ad17347f fix(core): apply safe defaults to all agents (#40316) 2026-08-03 17:56:04 -05:00
169 changed files with 2629 additions and 2028 deletions
+20 -33
View File
@@ -88,6 +88,7 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -284,13 +285,9 @@
"name": "@opencode-ai/console-function",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -298,7 +295,6 @@
"@tsconfig/node22": "22.0.2",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
},
},
@@ -361,18 +357,15 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
@@ -386,7 +379,6 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
@@ -911,7 +903,6 @@
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
@@ -1038,7 +1029,6 @@
"@shikijs/transformers": "3.20.0",
"@solid-primitives/resize-observer": "2.1.5",
"@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13",
"diff": "catalog:",
"js-base64": "3.7.7",
@@ -1084,17 +1074,16 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1220,9 +1209,9 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
@@ -4944,7 +4933,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5962,12 +5951,8 @@
"@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5982,6 +5967,8 @@
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5990,6 +5977,8 @@
"@ai-sdk/deepgram/@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=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
@@ -6024,14 +6013,18 @@
"@ai-sdk/mistral/@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=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
@@ -6350,10 +6343,6 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -6578,6 +6567,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"blume/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"blume/@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="],
"blume/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
@@ -6714,8 +6705,6 @@
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gitlab-ai-provider/openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -7380,10 +7369,6 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@opencode-ai/desktop/@actions/artifact/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
@@ -7620,6 +7605,8 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"blume/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"blume/@astrojs/mdx/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="],
"blume/@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.2.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w=="],
-1
View File
@@ -161,7 +161,6 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
+1
View File
@@ -444,6 +444,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
}
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
if (finishReason === "MAX_TOKENS") return "length"
if (
+22 -23
View File
@@ -5,6 +5,7 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { ProviderShared } from "../protocols/shared"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure")
@@ -19,7 +20,7 @@ export type LanguageModelOptions = AzureURL &
ProviderAuthOption<"optional"> & {
readonly apiVersion?: string
readonly queryParams?: Record<string, string>
readonly useCompletionUrls?: boolean
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Config = LanguageModelOptions
@@ -29,27 +30,22 @@ export type Settings = ProviderPackage.Settings &
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
})
const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat",
provider: id,
auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
})
export const routes = [responsesRoute, chatRoute]
@@ -59,7 +55,7 @@ const defaults = (input: Config) => {
apiKey: _,
apiVersion: _apiVersion,
resourceName: _resourceName,
useCompletionUrls: _useCompletionUrls,
useDeploymentBasedUrls: _useDeploymentBasedUrls,
baseURL: _baseURL,
queryParams: _queryParams,
...rest
@@ -80,37 +76,39 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
route.with({
auth: auth(input),
endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
},
endpoint: endpoint(input, modelID),
})
function endpoint(input: Config, modelID: string | ModelID) {
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
if (input.baseURL !== undefined && !new URL(input.baseURL).hostname.endsWith(".openai.azure.com")) {
return { baseURL, query: input.queryParams }
}
return { baseURL: `${baseURL}/v1`, query }
}
export const configure = (input: Config) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
configuredRoute(responsesRoute, input, modelID)
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
configuredRoute(chatRoute, input, modelID)
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
model: responses,
responses,
chat,
configure,
@@ -131,6 +129,7 @@ const config = (settings: Settings): Config => {
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
@@ -15,8 +15,7 @@ export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
// models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export const id = ProviderID.make("google-vertex")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
+15 -8
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -20,6 +20,7 @@ import {
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -155,7 +156,6 @@ export interface Interface {
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware
}
@@ -232,6 +232,17 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
@@ -248,7 +259,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
: Effect.fail(incompleteStreamError(route)),
),
),
)
@@ -308,7 +319,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
middleware: options?.http,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
@@ -418,10 +428,7 @@ const generateWith = (stream: Interface["stream"]) =>
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
+1 -1
View File
@@ -270,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" })
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
+1 -8
View File
@@ -23,11 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type {
HttpHandler,
HttpMiddleware,
HttpRequest,
HttpRequestTransform,
Transport as TransportDef,
TransportRuntime,
} from "./transport"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
+3 -5
View File
@@ -75,12 +75,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const transformed = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* prepareInput.transform?.(transformed) ?? Effect.void
const request = ProviderShared.jsonPost({
url: transformed.url,
body: transformed.body ?? "",
headers: Headers.fromInput(transformed.headers),
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return {
request,
-10
View File
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
@@ -32,7 +23,6 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
readonly middleware?: HttpMiddleware
}
+1
View File
@@ -105,6 +105,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
+8 -8
View File
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
export interface ToolModelOutputInput<Parameters, Output> {
readonly callID: ToolCallPart["id"]
readonly id: ToolCallPart["id"]
readonly parameters: Parameters
readonly output: Output
}
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
/** @internal */
readonly _project: (
parameters: Schema.Schema.Type<Parameters>,
callID: ToolCallPart["id"],
id: ToolCallPart["id"],
output: unknown,
) => ToolOutputType
/** @internal */
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Effect.succeed,
_encode: Effect.succeed,
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
_definition: new ToolDefinition({
name: "",
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput,
_decode: Schema.decodeUnknownEffect(config.parameters),
_encode: Schema.encodeEffect(config.success),
_project: (parameters, callID, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output),
_project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: false,
_definition: new ToolDefinition({
name: "",
@@ -239,12 +239,12 @@ const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
callID: ToolCallPart["id"],
id: ToolCallPart["id"],
output: unknown,
): ToolOutputType =>
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ callID, parameters, output }) ??
toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
)
+2 -2
View File
@@ -133,8 +133,8 @@ describe("llm route", () => {
Effect.gen(function* () {
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
+12
View File
@@ -67,6 +67,18 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+21
View File
@@ -209,6 +209,27 @@ describe("provider package entrypoints", () => {
expect(chat.route.id).toBe("azure-openai-chat")
})
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
query: { "api-version": "2025-01-01-preview" },
})
expect(gateway.route.endpoint.baseURL).toBe("https://gateway.example/azure")
expect(gateway.route.endpoint.query).toBeUndefined()
})
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", {
@@ -538,7 +538,8 @@ describe("Anthropic Messages route", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event",
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
})
}),
)
+28
View File
@@ -601,6 +601,34 @@ describe("Gemini route", () => {
}),
)
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
},
],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -56,13 +56,14 @@ describe("Google Vertex providers", () => {
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6")
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
}).model("claude-sonnet-4-6"),
model,
prompt: "Say hello.",
}),
).pipe(
@@ -97,6 +98,7 @@ describe("Google Vertex providers", () => {
),
)
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.")
}),
)
@@ -1136,9 +1136,12 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
+1 -1
View File
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
)
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([
+2 -2
View File
@@ -27,8 +27,8 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` },
toModelOutput: ({ id, parameters, output }) => [
{ type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
],
})
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", callID: "call-1" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
}),
).toEqual({
id: "permission-1",
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
}
}
+34 -2
View File
@@ -21,12 +21,24 @@ describe("adaptServerEvent", () => {
id: "evt_1",
created: 1,
type: "permission.asked",
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
properties: {
id: "perm_1",
sessionID: "ses_1",
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
current,
})
})
@@ -70,6 +82,26 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+2 -2
View File
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
? { messageID: event.data.source.messageID, callID: event.data.source.id }
: undefined,
},
current: event,
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
...base,
id: "evt_tool_start",
type: "session.tool.input.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
})
apply({
...base,
id: "evt_tool_delta",
type: "session.tool.input.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
})
apply({
...base,
id: "evt_tool_called",
type: "session.tool.called",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
})
apply({
...base,
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
id: "call_1",
metadata: {},
content: [{ type: "text", text: "done" }],
executed: true,
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
? item.content
: [
...item.content,
{
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming"
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
: tool,
)
case "session.tool.input.ended":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
)
case "session.tool.called":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
...tool,
executed: event.data.executed,
providerState: event.data.state,
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
tool.state.status === "running"
? {
...tool,
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
: tool,
)
case "session.tool.success":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "running") return tool
return {
...tool,
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
}
})
case "session.tool.failed":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
return {
...tool,
+14 -14
View File
@@ -72,7 +72,7 @@ export async function streamTurn(input: {
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
@@ -120,11 +120,11 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
@@ -134,13 +134,13 @@ export async function streamTurn(input: {
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
const current = tools.get(event.data.id) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.callID, current)
tools.set(event.data.id, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -149,13 +149,13 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.callID)
const current = tools.get(event.data.id)
if (!current) continue
current.metadata = event.data.metadata
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
@@ -164,8 +164,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
@@ -178,7 +178,7 @@ export async function streamTurn(input: {
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata,
@@ -188,12 +188,12 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata ?? current.metadata,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function replyPermission(input: {
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolCallId: input.event.data.source?.id ?? input.event.data.id,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
+19 -19
View File
@@ -300,7 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
tools.set(toolKey(event.data.assistantMessageID, event.data.id), {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
@@ -312,18 +312,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key)
tools.set(key, {
id: current?.id ?? partID(event.id),
@@ -340,18 +340,18 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.id))
if (current) {
current.metadata = event.data.metadata
}
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -365,11 +365,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
tool: current.tool,
state: {
status: "completed",
@@ -392,14 +392,14 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const key = toolKey(event.data.assistantMessageID, event.data.id)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const metadata = event.data.metadata ?? current.metadata
const content = event.data.content ?? nonEmptyToolContent(current.content)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
id: event.data.id,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
@@ -414,11 +414,11 @@ export async function runNonInteractivePrompt(input: Input) {
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
partID: current.id,
sessionID: input.sessionID,
messageID: event.data.assistantMessageID,
type: "tool",
callID: event.data.callID,
id: event.data.id,
tool: current.tool,
state: {
status: "error",
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.step.failed") {
if (
input.compatibility === "v1" &&
event.data.error.message === "Provider stream ended without a terminal finish event"
event.data.error.message === "The provider response ended unexpectedly."
) {
pendingStep = undefined
v1InvalidOutput = true
@@ -578,11 +578,11 @@ export async function runNonInteractivePrompt(input: Input) {
const key = toolKey(message.id, item.id)
if (renderedTools.has(key) || item.state.status === "streaming" || item.state.status === "running") continue
const part: MiniToolPart = {
id: projectedPartID(message.id, `tool-${item.id}`),
partID: projectedPartID(message.id, `tool-${item.id}`),
sessionID: input.sessionID,
messageID: message.id,
type: "tool",
callID: item.id,
id: item.id,
tool: item.name,
state:
item.state.status === "completed"
@@ -771,8 +771,8 @@ function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
function toolKey(messageID: string, id: string) {
return `${messageID}\u0000${id}`
}
function contentKey(messageID: string, ordinal: number) {
@@ -786,7 +786,7 @@ function projectedPartID(messageID: string, part: string) {
function fallbackTool(event: {
id: string
created: number
data: { assistantMessageID: string; callID: string }
data: { assistantMessageID: string; id: string }
}): ToolState {
return {
id: partID(event.id),
+8 -8
View File
@@ -200,7 +200,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
name: "shell",
}),
)
@@ -208,7 +208,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
@@ -217,7 +217,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
metadata: { phase: 1 },
}),
)
@@ -225,7 +225,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_ok",
id: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
@@ -235,7 +235,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
name: "read",
}),
)
@@ -243,7 +243,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
@@ -252,7 +252,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
metadata: { bytes: 0 },
}),
)
@@ -260,7 +260,7 @@ describe("acp event behavior", () => {
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
id: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
@@ -43,14 +43,14 @@ describe("acp permission behavior", () => {
permissionAsked("ses_allow", "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
source: { type: "tool", messageID: "msg_allow", id: "call_once" },
}),
)
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
@@ -166,7 +166,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
name: "edit",
}),
)
@@ -174,7 +174,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
input: { path: "file.ts", oldString: "before", newString: "after" },
executed: false,
}),
@@ -182,7 +182,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_edit", "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
)
},
@@ -192,7 +192,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_edit",
assistantMessageID: "msg_edit",
callID: "call_edit",
id: "call_edit",
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
content: [{ type: "text", text: "edited" }],
executed: true,
@@ -256,7 +256,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
name: "patch",
}),
)
@@ -264,7 +264,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.called", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
input: { patchText },
executed: false,
}),
@@ -272,7 +272,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_patch", "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
)
},
@@ -285,7 +285,7 @@ describe("acp permission behavior", () => {
durableEvent("session.tool.success", {
sessionID: "ses_patch",
assistantMessageID: "msg_patch",
callID: "call_patch",
id: "call_patch",
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
content: [{ type: "text", text: "patched" }],
executed: true,
@@ -499,7 +499,7 @@ function permissionAsked(
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
+10 -10
View File
@@ -109,7 +109,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
name: "shell",
},
},
@@ -121,7 +121,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
input: { command: "printf partial && false" },
executed: true,
},
@@ -133,7 +133,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
metadata: { checkpoint: 1 },
},
},
@@ -145,7 +145,7 @@ function failedTool(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_failed_tool",
callID: "call_failed_tool",
id: "call_failed_tool",
error: { type: "unknown", message: "tool failed" },
metadata: { checkpoint: 1 },
content: [{ type: "text", text: "partial output" }],
@@ -168,7 +168,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
name: "grep",
},
},
@@ -180,7 +180,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
input: { pattern: "needle" },
executed: true,
},
@@ -193,7 +193,7 @@ function successfulGrep(inputID: string): V2Event[] {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
id: "call_grep",
metadata: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("Provider stream ended without a terminal finish event"),
executionFailed("Provider stream ended without a terminal finish event"),
stepFailed("The provider response ended unexpectedly."),
executionFailed("The provider response ended unexpectedly."),
],
})
@@ -561,7 +561,7 @@ describe("runNonInteractivePrompt", () => {
type: "tool_use",
part: {
type: "tool",
callID: "call_failed_tool",
id: "call_failed_tool",
tool: "shell",
state: {
status: "error",
+16 -9
View File
@@ -605,7 +605,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly name: string
}
}
@@ -619,7 +619,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly text: string
}
}
@@ -633,7 +633,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: SessionMessage.ProviderState | undefined
@@ -649,7 +649,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -685,7 +685,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly content?:
| readonly [
@@ -1502,18 +1502,25 @@ export interface ProjectCopyApi<E = never> {
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly diff: VcsDiffOperation<E>
}
+14 -3
View File
@@ -210,6 +210,8 @@ import type {
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
@@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
+2 -5
View File
@@ -56,7 +56,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -84,7 +83,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const info = registration.info
const service = registration.service
if (service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
@@ -97,14 +95,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
if (failure !== undefined) return yield* Effect.fail(failure)
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
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) {
yield* announce("missing")
@@ -202,6 +202,8 @@ import type {
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsDiffInput,
@@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
method: "GET",
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
+31 -16
View File
@@ -297,7 +297,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -486,7 +486,7 @@ export type Pty = {
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionAnswer = Array<string>
@@ -530,6 +530,8 @@ export type ReferenceGitSource = {
export type ProjectCopyCopy = { directory: string }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = {
file: string
additions: number
@@ -742,7 +744,7 @@ export type SessionToolInputStarted = {
type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; name: string }
data: { sessionID: string; assistantMessageID: string; id: string; name: string }
}
export type SessionToolInputEnded = {
@@ -752,7 +754,7 @@ export type SessionToolInputEnded = {
type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; text: string }
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionCompactionAdmitted = {
@@ -913,7 +915,7 @@ export type SessionToolInputDelta = {
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
}
export type SessionToolProgress = {
@@ -922,7 +924,7 @@ export type SessionToolProgress = {
metadata?: { [x: string]: any }
type: "session.tool.progress"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
}
export type SessionCompactionDelta = {
@@ -1378,7 +1380,7 @@ export type SessionToolCalled = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState7
@@ -1745,6 +1747,8 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionInfo = {
@@ -1827,7 +1831,7 @@ export type SessionToolSuccess = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
executed: boolean
@@ -1845,7 +1849,7 @@ export type SessionToolFailed = {
data: {
sessionID: string
assistantMessageID: string
callID: string
id: string
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
@@ -4468,7 +4472,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["id"]
readonly action: {
@@ -4477,7 +4481,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["action"]
readonly resources: {
@@ -4486,7 +4490,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["resources"]
readonly save?: {
@@ -4495,7 +4499,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["save"]
readonly metadata?: {
@@ -4504,7 +4508,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["metadata"]
readonly source?: {
@@ -4513,7 +4517,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["source"]
readonly agent?: {
@@ -4522,7 +4526,7 @@ export type PermissionCreateInput = {
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["agent"]
}
@@ -4902,6 +4906,17 @@ export type ProjectCopyRefreshInput = {
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsInfo
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+2 -5
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -78,14 +76,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
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) {
announce("missing")
+9 -3
View File
@@ -9,14 +9,20 @@ if (mode === "record-start") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
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" })
.then(() => true)
.catch(() => false)
if (!owner) process.exit()
if (mode === "coordinated") {
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
@@ -44,6 +44,24 @@ test("ensures a missing service with native promises", async () => {
}
}, 15_000)
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
+1 -1
View File
@@ -44,7 +44,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
+18
View File
@@ -141,6 +141,24 @@ test("waits for a slow winner while bounding lock probes", async () => {
}
}, 15_000)
test("waits for a live contender when another contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("reports a contender that fails to start", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
-5
View File
@@ -12,18 +12,13 @@
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"zod": "catalog:"
}
}
-4
View File
@@ -78,18 +78,15 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
@@ -106,7 +103,6 @@
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"diff": "catalog:",
+20 -2
View File
@@ -1,11 +1,16 @@
export * as Agent from "./agent"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "./bus"
import { State } from "./state"
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
export const ID = Agent.ID
export type ID = typeof ID.Type
export const Name = Agent.Name
@@ -51,6 +56,13 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const global = yield* Global.Service
const permissions: Info["permissions"] = [
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
@@ -61,7 +73,13 @@ const layer = Layer.effect(
draft.default = id
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
const defaults = Info.default(id)
const current =
draft.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -114,4 +132,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
+15
View File
@@ -27,6 +27,21 @@ export function map(input: MapInput): Mapping | undefined {
}
case "@ai-sdk/amazon-bedrock/mantle":
return mapBedrockMantle(input, baseSettings)
case "@ai-sdk/azure":
return {
package: `@opencode-ai/ai/providers/azure/${input.settings.useCompletionUrls === true ? "chat" : "responses"}`,
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.resourceName === "string" ? { resourceName: input.settings.resourceName } : {}),
...(typeof input.settings.apiVersion === "string" ? { apiVersion: input.settings.apiVersion } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...(typeof input.settings.useDeploymentBasedUrls === "boolean"
? { useDeploymentBasedUrls: input.settings.useDeploymentBasedUrls }
: {}),
...mapOpenAIOptions(input.settings),
},
}
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
+1 -1
View File
@@ -413,7 +413,7 @@ function mapBodyToProviderOptions(model: Info, packageName: string) {
function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
return {
prompt: prompt(request),
maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output,
maxOutputTokens: request.generation?.maxTokens,
temperature: request.generation?.temperature,
stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop],
topP: request.generation?.topP,
+1 -1
View File
@@ -213,7 +213,7 @@ const layer = Layer.effect(
const provider = record.provider
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
if (providerID === Provider.ID.azure) {
return
}
+72 -78
View File
@@ -12,96 +12,84 @@ export const Plugin = define({
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
),
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
})
if (provider.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...provider.env] },
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
})
yield* ctx.catalog.transform((catalog) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(loaded.entries, "model")
if (configuredDefault !== undefined)
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined)
provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.variants !== undefined) {
model.variants ??= []
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.settings !== undefined)
model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.variants !== undefined) {
model.variants ??= []
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}
})
@@ -118,3 +106,9 @@ export const Plugin = define({
)
}),
})
function configuredProviders(entries: readonly Config.Entry[]) {
return entries
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((file) => Object.entries(file.info.providers ?? {}))
}
@@ -71,6 +71,14 @@ const layer = Layer.effect(
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
+4 -1
View File
@@ -2,6 +2,7 @@ export * as InstructionBuiltIns from "./builtins"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { SessionSchema } from "../session/schema"
import { Instructions } from "./index"
@@ -15,6 +16,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const location = yield* Location.Service
return Service.of({
load: (sessionID) =>
@@ -31,6 +33,7 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
].join("\n"),
),
@@ -58,4 +61,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
+14
View File
@@ -146,6 +146,20 @@ export const fromCatalogModel = (
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (draft.providerID !== Provider.ID.azure) return
const configured = draft.settings?.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== ""
? configured
: (process.env.AZURE_RESOURCE_NAME ?? process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME)
if (resourceName) draft.settings = { ...draft.settings, resourceName }
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL
.replaceAll("${AZURE_RESOURCE_NAME}", resourceName ?? "${AZURE_RESOURCE_NAME}")
.replaceAll(
"${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}",
resourceName ?? "${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}",
)
})
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
+17 -52
View File
@@ -1,17 +1,10 @@
export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Agent } from "../agent"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
@@ -100,38 +93,12 @@ Rules:
export const Plugin = define({
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: Permission.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: Permission.Ruleset = [
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* ctx.agent.transform((draft) => {
draft.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
@@ -139,18 +106,8 @@ export const Plugin = define({
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
@@ -159,10 +116,16 @@ export const Plugin = define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
item.permissions.push(
{ action: "question", resource: "*", effect: "deny" },
{ action: "subagent", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
item.name = Agent.Name.make("Explore")
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
@@ -170,7 +133,6 @@ export const Plugin = define({
item.mode = "subagent"
item.permissions.push(
...Permission.merge(
defaults,
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
@@ -178,9 +140,12 @@ export const Plugin = define({
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" },
],
readonlyExternalDirectory,
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
),
)
})
@@ -190,7 +155,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("title"), (item) => {
@@ -198,7 +163,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("summary"), (item) => {
@@ -206,7 +171,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
})
}),
+18 -3
View File
@@ -3,13 +3,14 @@ import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Stream } from "effect"
import { Bus } from "../bus"
import { ModelsDev } from "../models-dev"
import { Provider } from "../provider"
export const ModelsDevPlugin = define({
id: "opencode.models-dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
const loaded = { data: structuredClone(yield* modelsDev.get()) }
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
if (provider.environment.length === 0) continue
@@ -21,7 +22,10 @@ export const ModelsDevPlugin = define({
})
integrations.method.update({
integrationID,
method: { type: "env", names: [...provider.environment] },
method: {
type: "env",
names: environmentNames(provider),
},
})
}
})
@@ -39,7 +43,7 @@ export const ModelsDevPlugin = define({
yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() =>
modelsDev.get().pipe(
Effect.tap((data) => Effect.sync(() => (loaded.data = structuredClone(data)))),
Effect.tap((data) => Effect.sync(() => (loaded.data = snapshots(data)))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
@@ -48,3 +52,14 @@ export const ModelsDevPlugin = define({
)
}),
})
function environmentNames(provider: ModelsDev.Snapshot) {
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return structuredClone(data).filter(
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
+61 -3
View File
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -194,7 +251,9 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -263,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: sessionHook,
create: (input) =>
run(
host.session.create(
+2 -6
View File
@@ -1,7 +1,7 @@
import { AlibabaPlugin } from "./provider/alibaba"
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock"
import { AnthropicPlugin } from "./provider/anthropic"
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure"
import { AzurePlugin } from "./provider/azure"
import { CerebrasPlugin } from "./provider/cerebras"
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
@@ -11,8 +11,7 @@ import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
import { GitLabPlugin } from "./provider/gitlab"
import { GooglePlugin } from "./provider/google"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
import { GoogleVertexPlugin } from "./provider/google-vertex"
import { GroqPlugin } from "./provider/groq"
import { KiloPlugin } from "./provider/kilo"
import { LLMGatewayPlugin } from "./provider/llmgateway"
@@ -36,7 +35,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,
AzureCognitiveServicesPlugin,
AzurePlugin,
CerebrasPlugin,
CloudflareAIGatewayPlugin,
@@ -46,8 +44,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin,
GoogleVertexPlugin,
GroqPlugin,
KiloPlugin,
+4 -36
View File
@@ -19,7 +19,9 @@ export const AzurePlugin = define({
if (Provider.packageName(item.provider.package) !== "@ai-sdk/azure") continue
const configured = item.provider.settings?.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
typeof configured === "string" && configured.trim() !== ""
? configured
: (process.env.AZURE_RESOURCE_NAME ?? process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME)
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.settings = { ...provider.settings, resourceName }
@@ -36,9 +38,7 @@ export const AzurePlugin = define({
!evt.options.baseURL &&
(!Provider.isAISDK(evt.model.package) || typeof evt.model.settings?.baseURL !== "string")
) {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
throw new Error("Azure resource name is missing; set AZURE_RESOURCE_NAME or configure resourceName/baseURL")
}
}
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
@@ -58,35 +58,3 @@ export const AzurePlugin = define({
)
}),
})
export const AzureCognitiveServicesPlugin = define({
id: "opencode.provider.azure-cognitive-services",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
baseURL: `https://${resourceName}.cognitiveservices.azure.com/openai`,
}
})
}
})
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(
evt.sdk,
evt.model.modelID ?? evt.model.id,
Boolean(evt.options.useCompletionUrls),
)
}),
)
}),
})
@@ -92,6 +92,22 @@ export const GoogleVertexPlugin = define({
evt.options.fetch = authFetch(evt.options.fetch)
return
}
if (evt.package === "@ai-sdk/google-vertex/anthropic") {
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
const project = resolveProject(evt.options)
const location = String(resolveLocation(evt.options))
const regionalBaseURL =
(location === "eu" || location === "us") && project && !evt.options.baseURL
? `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`
: undefined
evt.sdk = mod.createVertexAnthropic({
...evt.options,
project,
location,
...(regionalBaseURL ? { baseURL: regionalBaseURL } : {}),
})
return
}
if (evt.package !== "@ai-sdk/google-vertex") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
const project = resolveProject(evt.options)
@@ -114,62 +130,3 @@ export const GoogleVertexPlugin = define({
)
}),
})
export const GoogleVertexAnthropicPlugin = define({
id: "opencode.provider.google-vertex-anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.settings?.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.settings?.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
provider.settings = { ...provider.settings, ...(project ? { project } : {}), location }
})
}
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
const project =
typeof evt.options.project === "string"
? evt.options.project
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT)
const location =
typeof evt.options.location === "string"
? evt.options.location
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global")
evt.sdk = mod.createVertexAnthropic({
...evt.options,
project,
location,
// Continental multi-regions (eu, us) require Regional Endpoint Platform
// domains; the default {region}-aiplatform.googleapis.com does not resolve.
...((location === "eu" || location === "us") && project && !evt.options.baseURL
? {
baseURL: `https://aiplatform.${location}.rep.googleapis.com/v1/projects/${project}/locations/${location}/publishers/anthropic/models`,
}
: {}),
})
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
}),
)
}),
})
@@ -1,16 +0,0 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GooglePlugin = define({
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
}),
)
}),
})
+8 -9
View File
@@ -225,15 +225,14 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
}),
)
@@ -23,13 +23,5 @@ export const OpenRouterPlugin = define({
}
}
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options)
}),
)
}),
})
-16
View File
@@ -6,7 +6,6 @@ import { App } from "../../app"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { Provider } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
@@ -162,21 +161,6 @@ export const XAIPlugin = define({
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
}),
)
}),
})
+8 -8
View File
@@ -111,9 +111,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
const latestText = (assistant: DraftAssistant | undefined) =>
@@ -331,7 +331,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft(
SessionMessage.AssistantTool.make({
type: "tool",
id: event.data.callID,
id: event.data.id,
name: event.data.name,
time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
@@ -343,13 +343,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "streaming") match.state.input = event.data.text
})
},
"session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
@@ -366,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata
}
@@ -376,7 +376,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
@@ -394,7 +394,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
const match = latestTool(draft, event.data.id)
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
+72 -39
View File
@@ -2,9 +2,11 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -48,9 +50,7 @@ interface Prepared {
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@@ -137,8 +137,7 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
@@ -189,55 +188,87 @@ export const layer = Layer.effect(
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
const given = new Map(
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system,
messages,
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
: []
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
})
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
// `tool.name` stays canonical so execution can translate renamed calls back.
const hooked = new Map(
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
system: contextEvent.system,
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
tools: hookedTools,
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
hooks
.trigger("session", "request", {
http: (request, handler) =>
Effect.gen(function* () {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
})
.pipe(
Effect.tap((event) =>
use: (item) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
middlewares.push(item)
}),
),
Effect.asVoid,
),
})
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
@@ -256,13 +287,15 @@ export const layer = Layer.effect(
}),
)
}
const executeTool: Prepared["executeTool"] = (executeInput) => {
const executeTool: Prepared["executeTool"] = (input) => {
if (stepLimitReached)
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
return tools
.execute(executeInput)
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}
return {
+1 -1
View File
@@ -488,7 +488,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
callID: tool.id,
id: tool.id,
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
@@ -23,6 +23,10 @@ export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavaila
{ providerID: Provider.ID, modelID: ID },
) {
override get message() {
if (this.providerID === "azure-cognitive-services")
return `Model unavailable: ${this.providerID}/${this.modelID}. This provider has been deprecated; use azure/${this.modelID} instead.`
if (this.providerID === "google-vertex-anthropic")
return `Model unavailable: ${this.providerID}/${this.modelID}. This provider has been deprecated; use google-vertex/${this.modelID} instead.`
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
@@ -78,7 +78,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by callID/ordinal rather than global position.
* and consumers fold by id/ordinal rather than global position.
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const tools = new Map<
@@ -188,14 +188,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
true,
)
const toolInput = fragments("tool input", (callID, value) =>
const toolInput = fragments("tool input", (id, value) =>
Effect.gen(function* () {
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
text: value,
})
}),
@@ -225,7 +225,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
assistantMessageID,
callID: event.id,
id: event.id,
name: event.name,
})
})
@@ -258,7 +258,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error: {
type: "tool.input-json",
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
@@ -272,14 +272,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (callID: string, error: SessionError.Error) {
const tool = tools.get(callID)
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const tool = tools.get(id)
if (!tool || tool.settled) return false
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
error,
...failureSnapshot(tool),
executed: tool.providerExecuted,
@@ -289,10 +289,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
for (const [id, tool] of tools) {
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
failed = (yield* failTool(callID, error)) || failed
failed = (yield* failTool(id, error)) || failed
}
return failed
})
@@ -328,9 +328,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* failTools(error, scope)
})
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
@@ -399,7 +399,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
delta: event.text,
})
return
@@ -424,7 +424,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
@@ -450,7 +450,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
executed,
@@ -461,7 +461,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
content: hostedContent(event.result),
executed,
resultState,
@@ -478,7 +478,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
id: event.id,
error:
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
@@ -508,30 +508,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
})
const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
const tool = tools.get(callID)
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
metadata: update,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
id: string,
name: string,
result: Tool.Result,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
tool.settled = true
const content =
typeof result.content === "string"
@@ -539,11 +539,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
id,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
+2 -1
View File
@@ -20,10 +20,11 @@ export function isRetryable(error: AIError) {
case "ProviderInternal":
case "Transport":
return true
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
+3 -3
View File
@@ -93,7 +93,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
input,
}
yield* hooks.trigger("tool", "execute.before", beforeEvent)
@@ -106,7 +106,7 @@ const layer = Layer.effect(
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
@@ -228,7 +228,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: Tool.CallID.make(input.call.id),
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+1 -1
View File
@@ -22,7 +22,7 @@ Location-scoped built-in layers acquire `Permission.Service` and every other req
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
```
+1 -1
View File
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
})
const result = yield* mcp
+1 -1
View File
@@ -129,7 +129,7 @@ export const Plugin = {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
+1 -1
View File
@@ -61,7 +61,7 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
+1 -1
View File
@@ -76,7 +76,7 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
+1 -1
View File
@@ -95,7 +95,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
+2 -2
View File
@@ -70,7 +70,7 @@ export const Plugin = {
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@@ -81,7 +81,7 @@ export const Plugin = {
title: "Questions",
metadata: {
kind: "question",
tool: { messageID: context.messageID, callID: context.callID },
tool: { messageID: context.messageID, id: context.id },
},
fields: [
toField(input.questions[0], 0),
+1 -1
View File
@@ -56,7 +56,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
+13 -8
View File
@@ -89,10 +89,10 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
callID: string,
id: string,
command: string,
) {
yield* runtime.job.wait({ id: callID }).pipe(
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
@@ -111,7 +111,7 @@ export const Plugin = {
: "Command cancelled"
return runtime.session.synthetic({
sessionID,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", state },
})
@@ -134,7 +134,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
@@ -176,7 +176,12 @@ export const Plugin = {
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
const workdir = yield* fsUtil.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
@@ -227,7 +232,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.callID,
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -236,7 +241,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -250,7 +255,7 @@ export const Plugin = {
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
+1 -1
View File
@@ -75,7 +75,7 @@ export const Plugin = {
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
+1 -1
View File
@@ -159,7 +159,7 @@ export const Plugin = {
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
id: context.id,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
+1 -1
View File
@@ -139,7 +139,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const { body, contentType } = yield* Effect.gen(function* () {
+1 -1
View File
@@ -47,7 +47,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export const Plugin = {
const source = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
+42 -9
View File
@@ -94,13 +94,13 @@ function experimental(info: typeof ConfigV1.Info.Type) {
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
...info.enabled_providers.map((resource) => ({
action: "provider.use" as const,
resource,
resource: providerID(resource),
effect: "allow" as const,
})),
]),
...(info.disabled_providers ?? []).map((resource) => ({
action: "provider.use" as const,
resource,
resource: providerID(resource),
effect: "deny" as const,
})),
]
@@ -188,7 +188,7 @@ function modelSelection(input?: string, variant?: string) {
if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined
const separator = input.indexOf("/")
return {
providerID: input.slice(0, separator),
providerID: providerID(input.slice(0, separator)),
model: input.slice(separator + 1),
...(variant === undefined || variant.length === 0 || variant.includes("#") ? {} : { variant }),
}
@@ -234,24 +234,57 @@ function migrateMcp(info: ConfigMCPV1.Info) {
function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(Object.entries(info).map(([name, provider]) => [name, migrateProvider(provider)]))
return Object.fromEntries(
Object.entries(info).flatMap(([name, provider]) => {
const id = providerID(name)
// If both names are present, keep the settings under the current name and ignore the old one.
if (id !== name && info[id]) return []
return [[id, migrateProvider(name, provider)]]
}),
)
}
function migrateProvider(info: ConfigProviderV1.Info) {
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
const vertexAnthropic = sourceID === "google-vertex-anthropic"
const legacyAzure = sourceID === "azure-cognitive-services"
const packageName = info.npm ? Provider.aisdk(info.npm) : undefined
const modelPackage = vertexAnthropic ? (packageName ?? Provider.aisdk("@ai-sdk/google-vertex/anthropic")) : undefined
const legacyAzureBaseURL =
legacyAzure && info.npm === "@ai-sdk/openai-compatible" && !info.api
? "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai"
: undefined
return {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
env: legacyAzure ? info.env?.filter((name) => name !== "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") : info.env,
// The current Google Vertex provider includes Gemini and Claude. Keep the Anthropic SDK on Claude models
// instead of changing the package inherited by every model on the provider.
package: vertexAnthropic ? undefined : packageName,
settings: info.api
? { ...options.settings, baseURL: info.api }
: legacyAzureBaseURL
? { ...options.settings, baseURL: legacyAzureBaseURL }
: options.settings,
headers: info.options && options.headers,
body: info.options && options.body,
models:
info.models &&
Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
Object.fromEntries(
Object.entries(info.models).map(([name, model]) => {
const migrated = migrateModel(model)
return [name, modelPackage && !migrated.package ? { ...migrated, package: modelPackage } : migrated]
}),
),
}
}
// Rename these only in files detected as V1 by a field that exists only in the old config format.
function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex"
return input
}
function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
const settings = info.options && ConfigProviderOptionsV1.model(info.options)
const costs = info.cost && [
+37 -4
View File
@@ -1,22 +1,27 @@
export * as Vcs from "./vcs"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
export { FileStatus, Mode }
export { FileStatus, Info, Mode }
export interface DiffOptions {
readonly context?: number
}
export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
@@ -38,8 +43,36 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
info: Effect.fn("Vcs.info")(function* () {
return state.info
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
@@ -55,5 +88,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
+7 -1
View File
@@ -3,7 +3,7 @@ export * as VcsGit from "./git"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
@@ -20,6 +20,12 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
return {
info: Effect.fn("VcsGit.info")(function* () {
const [current, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
return { branch: { current, default: root?.name } } satisfies Info
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+4 -1
View File
@@ -4,7 +4,7 @@ import path from "path"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs"
@@ -73,6 +73,9 @@ export function make(
})
return {
info: Effect.fn("VcsHg.info")(function* () {
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+36 -12
View File
@@ -1,3 +1,4 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
@@ -9,15 +10,19 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
[Global.node, globalLayer],
[Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>,
)
@@ -120,25 +125,35 @@ describe("Agent", () => {
const id = Agent.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
const info = yield* agent.get(id)
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("does not ambiently opt built-in agents into bash", () =>
it.effect("applies managed external directories without opting built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect(
host({
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
const agents = yield* agent.list()
@@ -152,6 +167,20 @@ describe("Agent", () => {
"title",
])
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
expect(
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
@@ -165,11 +194,6 @@ describe("Agent", () => {
host({
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
yield* Effect.forEach(["general", "explore"], (id) =>
+25
View File
@@ -16,6 +16,31 @@ describe("AISDKNative", () => {
})
})
test("maps Azure deployments and settings to native routes", () => {
const settings = {
apiKey: "secret",
resourceName: "resource",
apiVersion: "2025-01-01-preview",
queryParams: { feature: "enabled" },
useDeploymentBasedUrls: true,
reasoningEffort: "high",
}
expect(map("@ai-sdk/azure", settings, "deployment")).toEqual({
package: "@opencode-ai/ai/providers/azure/responses",
settings: {
apiKey: "secret",
resourceName: "resource",
apiVersion: "2025-01-01-preview",
queryParams: { feature: "enabled" },
useDeploymentBasedUrls: true,
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
expect(map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")?.package).toBe(
"@opencode-ai/ai/providers/azure/chat",
)
})
test("maps Bedrock provider and request options", () => {
expect(
map(
+17
View File
@@ -98,6 +98,23 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model({
...model("@openrouter/ai-sdk-provider"),
limit: { context: 500_000, output: 500_000 },
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.maxOutputTokens).toBeUndefined()
}),
)
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+14 -9
View File
@@ -20,10 +20,13 @@ import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies Permission.Ruleset
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
@@ -58,6 +61,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) =>
editor.update(build, (agent) => {
@@ -110,7 +114,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
@@ -128,7 +132,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
@@ -136,7 +140,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
])
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
@@ -270,6 +274,7 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [
new Config.Document({
type: "document",
@@ -287,13 +292,13 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
+105
View File
@@ -475,6 +475,111 @@ describe("Config", () => {
}),
)
it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
}),
)
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
}),
)
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
@@ -714,7 +714,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
id: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -725,7 +725,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
id: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
@@ -795,7 +795,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(event!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_hosted",
id: "call_hosted",
structured: {},
content: [],
result: { type: "json", value: [{ url: "https://example.com" }] },
@@ -806,7 +806,7 @@ describe("DatabaseMigration", () => {
expect(JSON.parse(failedEvent!.data)).toEqual({
sessionID: "ses_test",
assistantMessageID: "msg_tools",
callID: "call_failed",
id: "call_failed",
error: { type: "tool.execution", message: "timed out" },
metadata: { truncated: false },
executed: false,
+26 -7
View File
@@ -153,12 +153,16 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (!options?.git) return { tmp, vcs: undefined }
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
@@ -292,7 +296,7 @@ describeWatcher("LocationWatcher", () => {
})
}
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -322,7 +326,7 @@ describeWatcher("LocationWatcher", () => {
}),
)
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -359,7 +363,7 @@ describeWatcher("LocationWatcher", () => {
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -376,7 +380,7 @@ describeWatcher("LocationWatcher", () => {
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toMatchObject({ file: head })
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -401,7 +405,7 @@ describeWatcher("LocationWatcher", () => {
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
git: true,
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
@@ -411,4 +415,19 @@ describeWatcher("LocationWatcher", () => {
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
+2 -2
View File
@@ -5,8 +5,8 @@ import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
@@ -29,7 +29,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
]),
)
@@ -49,6 +49,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+44
View File
@@ -666,6 +666,50 @@ describe("LocationServiceMap", () => {
),
)
it.live("explains replacements for unavailable legacy provider models", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
for (const [providerID, replacement] of [
["azure-cognitive-services", "azure"],
["google-vertex-anthropic", "google-vertex"],
] as const) {
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
Session.Info.make({
id: Session.ID.make(`ses_removed_${providerID}`),
projectID: Project.ID.global,
title: "test",
model: {
id: Model.ID.make("chat"),
providerID: Provider.ID.make(providerID),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
providerID,
modelID: "chat",
})
expect(failure.message).toBe(
`Model unavailable: ${providerID}/chat. This provider has been deprecated; use ${replacement}/chat instead.`,
)
}
}),
),
),
)
it.live("preserves the selected catalog identity when the package model id differs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+1 -1
View File
@@ -928,7 +928,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
source: {
type: "tool",
messageID: toolIdentity.messageID,
callID: "call_mcp_permission",
id: "call_mcp_permission",
},
})
expect(calls).toBe(0)
+61 -1
View File
@@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { it } from "./lib/effect"
interface ModelOptions {
readonly providerID?: Provider.ID
readonly modelID?: string
readonly compatibility?: Compatibility
readonly settings?: Info["settings"]
@@ -25,7 +26,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
Info.make({
id: ID.make("test-model"),
modelID: ID.make(options.modelID ?? "api-test-model"),
providerID: Provider.ID.make("test-provider"),
providerID: options.providerID ?? Provider.ID.make("test-provider"),
name: "Test model",
compatibility: options.compatibility,
package: packageName,
@@ -42,6 +43,65 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
})
describe("ModelResolver", () => {
it.effect("constructs native Azure requests with deployment IDs and resolved resource URLs", () =>
Effect.gen(function* () {
const responses = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "responses-deployment",
settings: { resourceName: "modern-resource", apiVersion: "2025-01-01-preview" },
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "chat-deployment",
settings: { resourceName: "modern-resource", useCompletionUrls: true },
}),
)
const deployment = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "legacy-url-deployment",
settings: {
resourceName: "modern-resource",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
},
}),
)
const compatible = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
providerID: Provider.ID.azure,
modelID: "legacy-deployment",
settings: {
resourceName: "legacy-resource",
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
}),
)
expect(responses).toMatchObject({ id: "responses-deployment", provider: "azure" })
expect(responses.route).toMatchObject({
id: "azure-openai-responses",
endpoint: {
baseURL: "https://modern-resource.openai.azure.com/openai/v1",
query: { "api-version": "2025-01-01-preview" },
},
})
expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" })
expect(chat.route.id).toBe("azure-openai-chat")
expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" })
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://modern-resource.openai.azure.com/openai/deployments/legacy-url-deployment",
query: { "api-version": "2025-01-01-preview" },
})
expect(compatible).toMatchObject({ id: "legacy-deployment", provider: "azure" })
expect(compatible.route.endpoint.baseURL).toBe("https://legacy-resource.cognitiveservices.azure.com/openai")
}),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })

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