Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a462b1c101 | ||
|
|
e62ebd8fec | ||
|
|
195f592640 | ||
|
|
78769010a1 | ||
|
|
4e143e3a3e | ||
|
|
dab567aa2d | ||
|
|
9d35b04e13 | ||
|
|
273ab56949 | ||
|
|
302ba0ca0b | ||
|
|
d35e09f1fc | ||
|
|
fc34c74567 | ||
|
|
cb4f5cdea9 | ||
|
|
d34a0194ec | ||
|
|
43310f4d8c | ||
|
|
e22cfa435a | ||
|
|
93b1ccc029 | ||
|
|
faca2b90c1 | ||
|
|
76ff18afde | ||
|
|
9914c9af17 | ||
|
|
f202226bbc | ||
|
|
34198f422c | ||
|
|
cccdeef294 | ||
|
|
83c145f889 | ||
|
|
d353a6bc24 | ||
|
|
d25cc42d21 | ||
|
|
6039b894c5 | ||
|
|
b4fc5ef071 | ||
|
|
f6c8e35383 | ||
|
|
94564f3588 | ||
|
|
855bda8384 | ||
|
|
756488d534 | ||
|
|
22de34c4de | ||
|
|
bdb0c16a93 | ||
|
|
7f7eb2e7f8 | ||
|
|
e15fd0bb93 | ||
|
|
8f90697df8 | ||
|
|
17af25d1c1 | ||
|
|
3c81326a5e | ||
|
|
9f8d8f5b0e | ||
|
|
337993d53e | ||
|
|
e26abd8da9 | ||
|
|
8c1ce0b80c | ||
|
|
f8c3f560d4 | ||
|
|
7e43d3e3f5 | ||
|
|
52db7a76e2 | ||
|
|
be6e7b309e | ||
|
|
0af242974c | ||
|
|
27ac53aaac | ||
|
|
78015571bf | ||
|
|
e76cf967e6 | ||
|
|
c2723b5ea0 | ||
|
|
9675579796 | ||
|
|
4d8368970a | ||
|
|
2a7af6acd8 | ||
|
|
bfd707abc9 | ||
|
|
981e00971a | ||
|
|
c50d2b3656 | ||
|
|
ddad0988e7 | ||
|
|
cda8cc7285 | ||
|
|
b928a1fff9 | ||
|
|
04286d0415 | ||
|
|
33bb33ba90 | ||
|
|
b0ade40265 | ||
|
|
681594b551 | ||
|
|
edf7649400 | ||
|
|
3fc7486d15 | ||
|
|
8e353584c7 | ||
|
|
5c35ea2181 | ||
|
|
faf8713053 | ||
|
|
16c457e712 | ||
|
|
9818c9e8d0 | ||
|
|
5e41dbbcbf | ||
|
|
ba5c8d3822 | ||
|
|
10c90eb445 | ||
|
|
aa8a41d1b8 | ||
|
|
3f33be1928 | ||
|
|
42e6b7d541 | ||
|
|
ccb207f946 | ||
|
|
de1e0b5d6d |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +384,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -520,7 +520,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -536,9 +536,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.9",
|
||||
"@opentui/keymap": ">=0.2.9",
|
||||
"@opentui/solid": ">=0.2.9",
|
||||
"@opentui/core": ">=0.2.10",
|
||||
"@opentui/keymap": ">=0.2.10",
|
||||
"@opentui/solid": ">=0.2.10",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -558,7 +558,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -573,7 +573,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -657,7 +657,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -721,9 +721,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.9",
|
||||
"@opentui/keymap": "0.2.9",
|
||||
"@opentui/solid": "0.2.9",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1590,23 +1590,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.9", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.9", "@opentui/core-darwin-x64": "0.2.9", "@opentui/core-linux-arm64": "0.2.9", "@opentui/core-linux-x64": "0.2.9", "@opentui/core-win32-arm64": "0.2.9", "@opentui/core-win32-x64": "0.2.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Kmeqi+yiDau+P45xDeX08GS50FK917qVwuPTN7HGxsQ9Byt7Iifq/6OMiSnFULBzoZtECdKLgQF1XwLsNm1wig=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.9", "", { "dependencies": { "@opentui/core": "0.2.9" }, "peerDependencies": { "@opentui/react": "0.2.9", "@opentui/solid": "0.2.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yCc6L0Jqa8aVaNAVniTV5bNygJayUE6mxWfaBQY5VV5QwsZemXSeQQc4vP2eetH4Rrm1gGA59gLP+zh6+s5fvw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qpNSCELxRvBAx8Zneqz46FYYTvJNFjDvhqzAAZRNoaHathfU6X6iPxWMUqP/9ls5VcHFW1TDJdgtpsq1N/nHMQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
|
||||
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
|
||||
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
|
||||
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
|
||||
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=",
|
||||
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=",
|
||||
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=",
|
||||
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.9",
|
||||
"@opentui/keymap": "0.2.9",
|
||||
"@opentui/solid": "0.2.9",
|
||||
"@opentui/core": "0.2.10",
|
||||
"@opentui/keymap": "0.2.10",
|
||||
"@opentui/solid": "0.2.10",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -13,6 +13,7 @@ const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
@@ -31,8 +32,16 @@ export const DialogSelectMcp: Component = () => {
|
||||
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
|
||||
else await sdk.client.mcp.connect({ name })
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
}))
|
||||
@@ -67,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
return s?.status === "failed" ? s.error : undefined
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
@@ -78,9 +87,6 @@ export const DialogSelectMcp: Component = () => {
|
||||
<Show when={statusLabel()}>
|
||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||
</Show>
|
||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||
|
||||
@@ -145,7 +145,15 @@ const useMcpToggleMutation = () => {
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
onError: (err) => {
|
||||
@@ -316,7 +324,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
@@ -333,7 +341,16 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-base truncate">{name}</span>
|
||||
</span>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker truncate">
|
||||
{language.t("mcp.auth.clickToAuthenticate")}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
|
||||
@@ -14,12 +14,14 @@ export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const healthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||
return serverHealthy && !issue
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -41,7 +43,9 @@ export function StatusPopover() {
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base":
|
||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -276,6 +276,7 @@ export const dict = {
|
||||
"mcp.status.connected": "متصل",
|
||||
"mcp.status.failed": "فشل",
|
||||
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
||||
"mcp.auth.clickToAuthenticate": "انقر للمصادقة",
|
||||
"mcp.status.disabled": "معطل",
|
||||
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
|
||||
"dialog.directory.search.placeholder": "البحث في المجلدات",
|
||||
|
||||
@@ -276,6 +276,7 @@ export const dict = {
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "falhou",
|
||||
"mcp.status.needs_auth": "precisa de autenticação",
|
||||
"mcp.auth.clickToAuthenticate": "Clique para autenticar",
|
||||
"mcp.status.disabled": "desabilitado",
|
||||
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
|
||||
"dialog.directory.search.placeholder": "Buscar pastas",
|
||||
|
||||
@@ -300,6 +300,7 @@ export const dict = {
|
||||
"mcp.status.connected": "povezano",
|
||||
"mcp.status.failed": "neuspjelo",
|
||||
"mcp.status.needs_auth": "potrebna autentifikacija",
|
||||
"mcp.auth.clickToAuthenticate": "Kliknite za autentifikaciju",
|
||||
"mcp.status.disabled": "onemogućeno",
|
||||
|
||||
"dialog.fork.empty": "Nema poruka za fork",
|
||||
|
||||
@@ -298,6 +298,7 @@ export const dict = {
|
||||
"mcp.status.connected": "forbundet",
|
||||
"mcp.status.failed": "mislykkedes",
|
||||
"mcp.status.needs_auth": "kræver godkendelse",
|
||||
"mcp.auth.clickToAuthenticate": "Klik for at godkende",
|
||||
"mcp.status.disabled": "deaktiveret",
|
||||
|
||||
"dialog.fork.empty": "Ingen beskeder at forgrene fra",
|
||||
|
||||
@@ -282,6 +282,7 @@ export const dict = {
|
||||
"mcp.status.connected": "verbunden",
|
||||
"mcp.status.failed": "fehlgeschlagen",
|
||||
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
||||
"mcp.auth.clickToAuthenticate": "Zum Authentifizieren klicken",
|
||||
"mcp.status.disabled": "deaktiviert",
|
||||
"dialog.fork.empty": "Keine Nachrichten zum Abzweigen vorhanden",
|
||||
"dialog.directory.search.placeholder": "Ordner durchsuchen",
|
||||
|
||||
@@ -306,6 +306,7 @@ export const dict = {
|
||||
"mcp.status.failed": "failed",
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
||||
|
||||
"dialog.fork.empty": "No messages to fork from",
|
||||
|
||||
@@ -902,7 +903,7 @@ export const dict = {
|
||||
"settings.permissions.tool.read.title": "Read",
|
||||
"settings.permissions.tool.read.description": "Reading a file (matches the file path)",
|
||||
"settings.permissions.tool.edit.title": "Edit",
|
||||
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, patches, and multi-edits",
|
||||
"settings.permissions.tool.edit.description": "Modify files, including edits, writes, and patches",
|
||||
"settings.permissions.tool.glob.title": "Glob",
|
||||
"settings.permissions.tool.glob.description": "Match files using glob patterns",
|
||||
"settings.permissions.tool.grep.title": "Grep",
|
||||
|
||||
@@ -299,6 +299,7 @@ export const dict = {
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "fallido",
|
||||
"mcp.status.needs_auth": "necesita auth",
|
||||
"mcp.auth.clickToAuthenticate": "Haz clic para autenticar",
|
||||
"mcp.status.disabled": "deshabilitado",
|
||||
|
||||
"dialog.fork.empty": "No hay mensajes desde donde bifurcar",
|
||||
|
||||
@@ -277,6 +277,7 @@ export const dict = {
|
||||
"mcp.status.connected": "connecté",
|
||||
"mcp.status.failed": "échoué",
|
||||
"mcp.status.needs_auth": "nécessite auth",
|
||||
"mcp.auth.clickToAuthenticate": "Cliquez pour vous authentifier",
|
||||
"mcp.status.disabled": "désactivé",
|
||||
"dialog.fork.empty": "Aucun message à partir duquel bifurquer",
|
||||
"dialog.directory.search.placeholder": "Rechercher des dossiers",
|
||||
|
||||
@@ -275,6 +275,7 @@ export const dict = {
|
||||
"mcp.status.connected": "接続済み",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "認証が必要",
|
||||
"mcp.auth.clickToAuthenticate": "クリックして認証",
|
||||
"mcp.status.disabled": "無効",
|
||||
"dialog.fork.empty": "フォーク元のメッセージがありません",
|
||||
"dialog.directory.search.placeholder": "フォルダを検索",
|
||||
|
||||
@@ -275,6 +275,7 @@ export const dict = {
|
||||
"mcp.status.connected": "연결됨",
|
||||
"mcp.status.failed": "실패",
|
||||
"mcp.status.needs_auth": "인증 필요",
|
||||
"mcp.auth.clickToAuthenticate": "클릭하여 인증",
|
||||
"mcp.status.disabled": "비활성화됨",
|
||||
"dialog.fork.empty": "분기할 메시지 없음",
|
||||
"dialog.directory.search.placeholder": "폴더 검색",
|
||||
|
||||
@@ -302,6 +302,7 @@ export const dict = {
|
||||
"mcp.status.connected": "tilkoblet",
|
||||
"mcp.status.failed": "mislyktes",
|
||||
"mcp.status.needs_auth": "trenger autentisering",
|
||||
"mcp.auth.clickToAuthenticate": "Klikk for å autentisere",
|
||||
"mcp.status.disabled": "deaktivert",
|
||||
|
||||
"dialog.fork.empty": "Ingen meldinger å forgrene fra",
|
||||
|
||||
@@ -277,6 +277,7 @@ export const dict = {
|
||||
"mcp.status.connected": "połączono",
|
||||
"mcp.status.failed": "niepowodzenie",
|
||||
"mcp.status.needs_auth": "wymaga autoryzacji",
|
||||
"mcp.auth.clickToAuthenticate": "Kliknij, aby się uwierzytelnić",
|
||||
"mcp.status.disabled": "wyłączone",
|
||||
"dialog.fork.empty": "Brak wiadomości do rozwidlenia",
|
||||
"dialog.directory.search.placeholder": "Szukaj folderów",
|
||||
|
||||
@@ -299,6 +299,7 @@ export const dict = {
|
||||
"mcp.status.connected": "подключено",
|
||||
"mcp.status.failed": "ошибка",
|
||||
"mcp.status.needs_auth": "требуется авторизация",
|
||||
"mcp.auth.clickToAuthenticate": "Нажмите, чтобы авторизоваться",
|
||||
"mcp.status.disabled": "отключено",
|
||||
|
||||
"dialog.fork.empty": "Нет сообщений для ответвления",
|
||||
|
||||
@@ -299,6 +299,7 @@ export const dict = {
|
||||
"mcp.status.connected": "เชื่อมต่อแล้ว",
|
||||
"mcp.status.failed": "ล้มเหลว",
|
||||
"mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
|
||||
"mcp.auth.clickToAuthenticate": "คลิกเพื่อยืนยันตัวตน",
|
||||
"mcp.status.disabled": "ปิดใช้งาน",
|
||||
|
||||
"dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
|
||||
|
||||
@@ -304,6 +304,7 @@ export const dict = {
|
||||
"mcp.status.connected": "bağlı",
|
||||
"mcp.status.failed": "başarısız",
|
||||
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
|
||||
"mcp.auth.clickToAuthenticate": "Kimlik doğrulamak için tıklayın",
|
||||
"mcp.status.disabled": "devre dışı",
|
||||
|
||||
"dialog.fork.empty": "Dallandırılacak mesaj yok",
|
||||
|
||||
@@ -319,6 +319,7 @@ export const dict = {
|
||||
"mcp.status.connected": "已连接",
|
||||
"mcp.status.failed": "失败",
|
||||
"mcp.status.needs_auth": "需要授权",
|
||||
"mcp.auth.clickToAuthenticate": "点击进行授权",
|
||||
"mcp.status.disabled": "已禁用",
|
||||
|
||||
"dialog.fork.empty": "没有可用于分叉的消息",
|
||||
|
||||
@@ -299,6 +299,7 @@ export const dict = {
|
||||
"mcp.status.connected": "已連線",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "需要授權",
|
||||
"mcp.auth.clickToAuthenticate": "點擊以進行授權",
|
||||
"mcp.status.disabled": "已停用",
|
||||
|
||||
"dialog.fork.empty": "沒有可用於分支的訊息",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,8 +9,8 @@ export const config = {
|
||||
github: {
|
||||
repoUrl: "https://github.com/anomalyco/opencode",
|
||||
starsFormatted: {
|
||||
compact: "150K",
|
||||
full: "150,000",
|
||||
compact: "160K",
|
||||
full: "160,000",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export const config = {
|
||||
|
||||
// Static stats (used on landing page)
|
||||
stats: {
|
||||
contributors: "850",
|
||||
commits: "11,000",
|
||||
monthlyUsers: "6.5M",
|
||||
contributors: "900",
|
||||
commits: "13,000",
|
||||
monthlyUsers: "7.5M",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { produce, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Instance } from "./instance"
|
||||
|
||||
type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
@@ -56,6 +57,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Instance.Service
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -5,11 +5,6 @@ function truthy(key: string) {
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
function falsy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -19,15 +14,12 @@ function number(key: string) {
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
export const Flag = {
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
|
||||
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
|
||||
|
||||
OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"),
|
||||
OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
|
||||
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
|
||||
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
|
||||
@@ -40,13 +32,11 @@ export const Flag = {
|
||||
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
|
||||
OPENCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
|
||||
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
|
||||
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE,
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
@@ -61,24 +51,17 @@ export const Flag = {
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
|
||||
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
|
||||
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Layer, LayerMap } from "effect"
|
||||
import { Instance } from "./instance"
|
||||
import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
|
||||
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
|
||||
lookup: (ref: Instance.Ref) => {
|
||||
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
|
||||
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
|
||||
},
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Context } from "effect"
|
||||
|
||||
export * as Instance from "./instance"
|
||||
|
||||
export type Ref = {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { CatalogModelStatus } from "./model-status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Global } from "./global"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Flock } from "./util/flock"
|
||||
import { Hash } from "./util/hash"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
|
||||
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
const CostTier = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -110,14 +112,21 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||
|
||||
type Requirements = AppFileSystem.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
||||
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
retryOn: "errors-and-responses",
|
||||
times: 2,
|
||||
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
const filepath = path.join(
|
||||
@@ -136,7 +145,7 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||
HttpClientRequest.setHeader("User-Agent", Installation.userAgent(flags.client)),
|
||||
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.text),
|
||||
Effect.timeout("10 seconds"),
|
||||
@@ -212,7 +221,6 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export * as ModelsDev from "./models"
|
||||
@@ -0,0 +1,71 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { AuthV2 } from "../auth"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AuthPlugin } from "./auth"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly wait: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
const npm = yield* Npm.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(AuthV2.Service, auth),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AuthPlugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Catalog.defaultLayer),
|
||||
Layer.provide(PluginV2.defaultLayer),
|
||||
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
)
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Catalog } from "../catalog"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelsDev } from "../models"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
function released(date: string) {
|
||||
const time = Date.parse(date)
|
||||
@@ -10,6 +10,7 @@ export const NvidiaPlugin = PluginV2.define({
|
||||
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface RunResult {
|
||||
readonly exitCode: number
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
readonly stdoutTruncated: boolean
|
||||
readonly stderrTruncated: boolean
|
||||
}
|
||||
|
||||
export type Interface = ChildProcessSpawner["Service"] & {
|
||||
@@ -147,7 +148,8 @@ export const layer = Layer.effect(
|
||||
exitCode,
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated,
|
||||
stdoutTruncated: stdout.truncated,
|
||||
stderrTruncated: stderr.truncated,
|
||||
} satisfies RunResult
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("normalizes provider baseURL into endpoint url", () =>
|
||||
@@ -4,11 +4,10 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ModelsDev } from "../../src/provider/models"
|
||||
import { it } from "../lib/effect"
|
||||
import { ModelsDev } from "@opencode-ai/core/models"
|
||||
import { it } from "./lib/effect"
|
||||
import { rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
|
||||
// resolve providers without network. These tests need to drive the on-disk
|
||||
@@ -93,7 +92,6 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
|
||||
Layer.fresh(ModelsDev.layer).pipe(
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ client: "test-client" })),
|
||||
)
|
||||
|
||||
const writeCache = (data: object, mtimeMs?: number) =>
|
||||
@@ -138,14 +136,14 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns {} when disk empty and fetch disabled", () =>
|
||||
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result).toEqual({})
|
||||
expect(Object.keys(result).length).toBeGreaterThan(0)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls).toEqual([])
|
||||
}),
|
||||
@@ -207,7 +205,7 @@ describe("ModelsDev Service", () => {
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
expect(final.calls[0].userAgent).toContain("/test-client")
|
||||
expect(final.calls[0].userAgent).toContain("/cli")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -257,7 +255,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual(fixture)
|
||||
// withTransientReadRetry retries 5xx, so calls may be > 1.
|
||||
// retryTransient retries 5xx, so calls may be > 1.
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { it, model } from "./provider-helper"
|
||||
|
||||
const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer))
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
|
||||
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper"
|
||||
import { fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const gitlabSDKOptions: Record<string, unknown>[] = []
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { it, model } from "./provider-helper"
|
||||
|
||||
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { it, model } from "./provider-helper"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"nvidia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
expect(ignored.provider.options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: {},
|
||||
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Option } from "effect"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
@@ -8,6 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and cancels paid models without credentials", () =>
|
||||
@@ -190,6 +192,6 @@ describe("OpencodePlugin", () => {
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer)),
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
|
||||
)
|
||||
})
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk } from "./provider-helper"
|
||||
|
||||
const it = testEffect(PluginV2.defaultLayer)
|
||||
@@ -20,7 +20,8 @@ describe("AppProcess", () => {
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hi\n")
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.stdoutTruncated).toBe(false)
|
||||
expect(result.stderrTruncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -84,17 +85,31 @@ describe("AppProcess", () => {
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"truncates output when maxOutputBytes is set",
|
||||
"truncates stdout when maxOutputBytes is set",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.stdoutTruncated).toBe(true)
|
||||
expect(result.stderrTruncated).toBe(false)
|
||||
expect(result.stdout.length).toBe(5)
|
||||
expect(result.stdout.toString("utf8")).toBe("01234")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"truncates stderr when maxErrorBytes is set",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdoutTruncated).toBe(false)
|
||||
expect(result.stderrTruncated).toBe(true)
|
||||
expect(result.stderr.length).toBe(5)
|
||||
expect(result.stderr.toString("utf8")).toBe("01234")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"result includes command description",
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
ProviderPlugins.map((item) => item.id),
|
||||
"nvidia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(ignored.provider.options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -58,7 +58,7 @@ async function checkMacosApp(appName: string) {
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = execFilePromise("where", [appName]).toString()
|
||||
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -19,7 +19,7 @@ declare module "virtual:opencode-server" {
|
||||
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||
}
|
||||
export namespace Database {
|
||||
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
|
||||
export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
|
||||
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||
}
|
||||
export namespace JsonMigration {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.49"
|
||||
version = "1.14.51"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.51/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -166,11 +166,11 @@ import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
const names = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(names, (name) =>
|
||||
cassettes
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
.read(name)
|
||||
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
@@ -196,14 +196,13 @@ type RecordReplayOptions = {
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
| File | Purpose |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — `fileSystem` / `memory` adapters, error types. |
|
||||
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
@@ -14,13 +14,24 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFo
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata?: CassetteMetadata,
|
||||
) => Effect.Effect<void, UnsafeCassetteError>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
@@ -44,6 +55,9 @@ const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
@@ -92,11 +106,9 @@ export const fileSystem = (
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
yield* failIfUnsafe(name, findings)
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
@@ -133,17 +145,17 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
append: (name, interaction, metadata) => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const existingFindings = accumulatedFindings.get(name)
|
||||
const findings = existingFindings ?? []
|
||||
if (!existingFindings) accumulatedFindings.set(name, findings)
|
||||
findings.push(...secretFindings(interaction))
|
||||
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
|
||||
return failIfUnsafe(name, findings)
|
||||
},
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
@@ -100,9 +100,11 @@ export const recordingLayer = (
|
||||
...captured,
|
||||
}),
|
||||
}
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
yield* cassetteService
|
||||
.append(name, interaction, options.metadata)
|
||||
.pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
|
||||
@@ -7,10 +7,9 @@ export type {
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
export const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import { Effect, Ref, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
import type { Interaction } from "./schema"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||
export const resolveAutoMode = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
@@ -95,8 +97,6 @@ export const redactHeaders = (
|
||||
)
|
||||
}
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Effect, Option, Ref, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { canonicalizeJson, decodeJson } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { canonicalizeJson, decodeJson, safeText } from "./matching"
|
||||
import { makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import type { RecordReplayMode } from "./effect"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
|
||||
@@ -53,7 +54,7 @@ const decodeFrameText = (frame: WebSocketFrame) =>
|
||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||
Effect.sync(() => {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
throw new Error(`${message}: expected ${safeText(expected)}, received ${safeText(actual)}`)
|
||||
})
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
@@ -61,7 +62,7 @@ const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone:
|
||||
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
|
||||
if (!expected)
|
||||
return Effect.sync(() => {
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${safeText(actual)}`)
|
||||
})
|
||||
const expectedText = decodeFrameText(expected)
|
||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||
@@ -98,12 +99,13 @@ export const makeWebSocketExecutor = <E>(
|
||||
const closeOnce = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(closed, true)) return
|
||||
yield* connection.close
|
||||
yield* appendOrFail(
|
||||
options.cassette,
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
).pipe(Effect.orDie)
|
||||
yield* options.cassette
|
||||
.append(
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
return {
|
||||
sendText: (message) =>
|
||||
@@ -111,10 +113,7 @@ export const makeWebSocketExecutor = <E>(
|
||||
.sendText(message)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
server.push(encodeFrame(message))
|
||||
return message
|
||||
}),
|
||||
Stream.tap((message) => Effect.sync(() => server.push(encodeFrame(message)))),
|
||||
),
|
||||
close: closeOnce,
|
||||
}
|
||||
@@ -130,20 +129,22 @@ export const makeWebSocketExecutor = <E>(
|
||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||
const index = yield* replay.cursor
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||
if (!interaction)
|
||||
return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${redactUrl(request.url)}`))
|
||||
yield* replay.advance
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||
const messageIndex = yield* Ref.make(0)
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||
const current = yield* Ref.get(messageIndex)
|
||||
yield* compareClientMessage(
|
||||
message,
|
||||
interaction.client[current],
|
||||
current,
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
yield* Ref.update(messageIndex, (value) => value + 1)
|
||||
}),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||
close: Effect.gen(function* () {
|
||||
|
||||
@@ -323,4 +323,118 @@ describe("http-recorder", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("auto mode records to disk when the cassette is missing", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
|
||||
})
|
||||
const url = `http://127.0.0.1:${server.port}/echo`
|
||||
// CI=true forces replay; clear it so we exercise the local-dev auto-record path.
|
||||
const previous = process.env.CI
|
||||
delete process.env.CI
|
||||
try {
|
||||
const result = await runWith("auto-record", { directory, mode: "auto" }, post(url, { step: 1 }))
|
||||
expect(result).toBe('{"reply":"recorded"}')
|
||||
expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
|
||||
} finally {
|
||||
if (previous !== undefined) process.env.CI = previous
|
||||
}
|
||||
})
|
||||
|
||||
test("passthrough mode bypasses the recorder entirely", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("from-upstream") })
|
||||
const url = `http://127.0.0.1:${server.port}/path`
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-passthrough-"))
|
||||
|
||||
const result = await runWith("passthrough-noop", { directory, mode: "passthrough" }, post(url, {}))
|
||||
expect(result).toBe("from-upstream")
|
||||
expect(fs.existsSync(path.join(directory, "passthrough-noop.json"))).toBe(false)
|
||||
})
|
||||
|
||||
test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
|
||||
const url = `http://127.0.0.1:${server.port}/leaky`
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
|
||||
|
||||
const exit = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
post(url, { ok: true }).pipe(
|
||||
Effect.provide(HttpRecorder.cassetteLayer("unsafe-record", { directory, mode: "record" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(failureText(exit)).toContain("contains possible secrets")
|
||||
expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
|
||||
})
|
||||
|
||||
test("Cassette.list enumerates recorded cassette names", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
|
||||
await seedCassetteDirectory(directory, "alpha/one", [
|
||||
{
|
||||
transport: "http",
|
||||
request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
|
||||
response: { status: 200, headers: {}, body: "a" },
|
||||
},
|
||||
])
|
||||
await seedCassetteDirectory(directory, "beta", [
|
||||
{
|
||||
transport: "http",
|
||||
request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
|
||||
response: { status: 200, headers: {}, body: "b" },
|
||||
},
|
||||
])
|
||||
|
||||
const names = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
return yield* cassette.list()
|
||||
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
expect(names).toEqual(["alpha/one", "beta"])
|
||||
})
|
||||
|
||||
test("WebSocket replay decodes binary frames recorded as base64", async () => {
|
||||
const binaryServer = new Uint8Array([1, 2, 3, 4])
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/binary",
|
||||
cassette,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/binary",
|
||||
headers: Headers.fromInput({}),
|
||||
})
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((m) => Effect.sync(() => messages.push(m))))
|
||||
yield* connection.close
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(messages[0] as Uint8Array)).toEqual([1, 2, 3, 4])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.memory({
|
||||
"ws/binary": [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/binary", headers: {} },
|
||||
client: [],
|
||||
server: [
|
||||
{ kind: "binary", body: Buffer.from(binaryServer).toString("base64"), bodyEncoding: "base64" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.51",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -198,5 +198,6 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
*/
|
||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -171,6 +171,7 @@ export const ToolError = Schema.Struct({
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
@@ -112,17 +112,29 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
|
||||
|
||||
const dispatched = yield* Effect.forEach(
|
||||
state.toolCalls,
|
||||
(call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
|
||||
(call) =>
|
||||
dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
|
||||
{ concurrency },
|
||||
)
|
||||
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
|
||||
const resultStream = Stream.fromIterable(
|
||||
dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
|
||||
)
|
||||
|
||||
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
|
||||
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
|
||||
|
||||
return resultStream.pipe(
|
||||
Stream.concat(
|
||||
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
|
||||
loop(
|
||||
followUpRequest(
|
||||
request,
|
||||
state,
|
||||
dispatched.map(([call, result]) => [call, result] as const),
|
||||
),
|
||||
step + 1,
|
||||
totalUsage,
|
||||
totalProviderMetadata,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
@@ -215,7 +227,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
| "reasoningTokens"
|
||||
| "totalTokens"
|
||||
const sum = (key: UsageKey) =>
|
||||
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0)
|
||||
left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
|
||||
|
||||
return new Usage({
|
||||
inputTokens: sum("inputTokens"),
|
||||
@@ -264,16 +276,20 @@ const appendStreamingText = (
|
||||
state.assistantContent.push({ type, text, providerMetadata })
|
||||
}
|
||||
|
||||
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
|
||||
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
|
||||
const tool = tools[call.name]
|
||||
if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
|
||||
if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
|
||||
if (!tool.execute)
|
||||
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
|
||||
return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
|
||||
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
|
||||
Effect.succeed({
|
||||
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
|
||||
error: failure.error,
|
||||
}),
|
||||
),
|
||||
Effect.map((result) => ("result" in result ? result : { result })),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,10 +310,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
|
||||
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
|
||||
)
|
||||
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
|
||||
result.type === "error"
|
||||
? [
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
|
||||
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
|
||||
]
|
||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user