mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-28 11:37:37 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7076a878a4 | ||
|
|
45b91eed82 | ||
|
|
39e1ce55bc | ||
|
|
d9f54392ba | ||
|
|
d73396ab3d | ||
|
|
0caae608a2 | ||
|
|
96f23508be | ||
|
|
28bb0a7158 | ||
|
|
3d109828ff | ||
|
|
c0d49f101c | ||
|
|
be2446e188 | ||
|
|
107966eddd | ||
|
|
f5e580cde1 | ||
|
|
4428a77acd |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -194,7 +194,7 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.117",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -405,7 +405,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/desktop": {
|
||||
@@ -514,7 +513,7 @@
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1106,10 +1105,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.117",
|
||||
"@effect/platform-node": "4.0.0-rc.117",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.117",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.13",
|
||||
@@ -1149,8 +1148,7 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"fast-check": "4.9.0",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1682,13 +1680,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.117", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.117" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IWAn2rU/RZLStkXfcdF1nGxcKQ7kbHct7N98T/5yOmSYiXvD8ZIxsGZhuAcRPPjc0NbNU2Ay8DZLPesyhJhUgA=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.117", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.117", "undici": "^8.10.2" }, "peerDependencies": { "effect": "^4.0.0-rc.117", "redis": ">=5.0.0 <7.0.0" } }, "sha512-WbPcvQDkqXFnCeG6LohJ9eVNapCDhZbCpezhYKxm0IsZEk7swakRh7RkC1t2CXzd3hGmszTv2k9e/pXyZuL+wQ=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.117", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.117" } }, "sha512-GyRkEfLBG49MwJe+/XDPiXYcQ44RA9p2z0AurlsWhR1cd1PqOUMEZU1pORL+/R5hR8aH6ZuqoJYD8lAc6h+wlA=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.117", "", { "peerDependencies": { "effect": "^4.0.0-rc.117" } }, "sha512-UjgkEmR+s5Rc870sDsDzu/8AA5KQqcBeq5qgH6AFe5OYfv3GCMfoQTkIvnl+Q3M5KaZosYgwwEy9uij/J2HOJw=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
|
||||
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
|
||||
|
||||
@@ -2022,6 +2020,18 @@
|
||||
|
||||
"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
@@ -3856,7 +3866,7 @@
|
||||
|
||||
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.117", "", {}, "sha512-UUyi9QiOW4nySFIBM3KnYbNMd9EZliOdKfBcyLD0mPMPheO2fkuXS5y0opjWc9oqzlJxPQICEGm8gGw3kh9j/w=="],
|
||||
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4694,7 +4704,7 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -4742,6 +4752,10 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
@@ -4784,6 +4798,8 @@
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
||||
@@ -5580,7 +5596,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -6032,6 +6048,8 @@
|
||||
|
||||
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
@@ -6632,8 +6650,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"unifont/undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776683584,
|
||||
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
|
||||
"lastModified": 1790510107,
|
||||
"narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
|
||||
"rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+5
-6
@@ -42,10 +42,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.117",
|
||||
"@effect/platform-node": "4.0.0-rc.117",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.117",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -79,8 +79,7 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"fast-check": "4.9.0",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -98,11 +98,11 @@ When a provider supports multiple physical transports, selection remains executi
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
|
||||
### URL Construction
|
||||
|
||||
|
||||
+21
-10
@@ -752,7 +752,10 @@ const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"
|
||||
|
||||
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
|
||||
auth. `Generation.await` and `Generation.events` fail with a
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
|
||||
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
|
||||
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
|
||||
provider job, which keeps running and billing: call `cancel()` to stop it. Failed,
|
||||
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
|
||||
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
|
||||
a video is still returned and as a `ContentPolicy` reason when nothing is.
|
||||
@@ -773,7 +776,9 @@ Provider notes:
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
|
||||
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
|
||||
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
|
||||
created; `refresh()` resolves to a new handle.
|
||||
created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`,
|
||||
aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException`
|
||||
unless `abort(reason)` passed one), while `break` stops a stream without throwing.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
@@ -871,11 +876,12 @@ for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode."
|
||||
## Transcription
|
||||
|
||||
Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream,
|
||||
Deepgram answers inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream` work on all of
|
||||
them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with `UnsupportedOperation`
|
||||
elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`, `Deepgram`, and `AssemblyAI`
|
||||
facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower
|
||||
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
|
||||
Deepgram and ElevenLabs answer inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream`
|
||||
work on all of them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with
|
||||
`UnsupportedOperation` elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`,
|
||||
`Deepgram`, `ElevenLabs`, and `AssemblyAI` facades. Common fields (`language`, `prompt`,
|
||||
`timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower natively or fail with a typed `AIError` before
|
||||
any network call; a route may return more than asked.
|
||||
|
||||
```ts
|
||||
import { Console, Effect, Stream } from "effect"
|
||||
@@ -887,7 +893,7 @@ const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
const program = Effect.gen(function* () {
|
||||
const audio = yield* Media.file("./call.mp3")
|
||||
|
||||
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0").
|
||||
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0", "speaker_0").
|
||||
const response = yield* Transcription.generate({
|
||||
model: Deepgram.configure({ apiKey }).transcription("nova-3"),
|
||||
audio,
|
||||
@@ -897,7 +903,7 @@ const program = Effect.gen(function* () {
|
||||
response.text // "Hello from OpenCode."
|
||||
response.segments // [{ text, startSeconds, endSeconds, speaker: "0" }]
|
||||
response.words // [{ text, startSeconds, endSeconds, speaker, confidence }]
|
||||
response.language // the provider's own value, lowercased ("en", "english", "en_us")
|
||||
response.language // the provider's own value, lowercased ("en", "eng", "english", "en_us")
|
||||
|
||||
// Text deltas as the model transcribes, then one finish carrying the whole transcript.
|
||||
yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
|
||||
@@ -921,7 +927,12 @@ Provider notes:
|
||||
- **OpenAI** takes inline audio only; `diarize` needs `gpt-4o-transcribe-diarize`, timestamps need `whisper-1`, and `whisper-1` does not stream.
|
||||
- **Gemini** needs a transcribe model (`gemini-3.5-transcribe`); `prompt` and `speakers` fail typed.
|
||||
- **Deepgram** detects the language unless `language` is set; vocabulary goes in `providerOptions.keyterm`.
|
||||
- **AssemblyAI** uploads inline audio before submitting and is the only route that accepts `speakers`.
|
||||
- **ElevenLabs** (`scribe_v2`) uploads inline audio as the multipart `file` and sends a URL as `source_url`. Words
|
||||
always carry timestamps, and segments are speaker turns, so `diarize`, `timestamps: "segment"`, or `speakers` turns
|
||||
on diarization. `speakers` is an upper bound (`num_speakers`); `prompt` fails typed (vocabulary goes in
|
||||
`providerOptions.keyterms`), as do webhook delivery and per-channel output (`use_multi_channel` without
|
||||
`multichannel_output_style: "combined"`).
|
||||
- **AssemblyAI** uploads inline audio before submitting and treats `speakers` as the exact speaker count.
|
||||
|
||||
The promise client mirrors the Effect API:
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
|
||||
pending); phase 5 proposal.
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -271,8 +270,8 @@ Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incre
|
||||
#### Transcription (STT)
|
||||
|
||||
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`; new `AssemblyAI`
|
||||
facade).
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
|
||||
`assemblyai-transcription`; new `AssemblyAI` facade).
|
||||
|
||||
```ts
|
||||
const request = Transcription.request({
|
||||
@@ -281,7 +280,7 @@ const request = Transcription.request({
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // exact speaker count (AssemblyAI only)
|
||||
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
@@ -309,17 +308,23 @@ upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
|
||||
Settled rules:
|
||||
|
||||
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
|
||||
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram and AssemblyAI always return
|
||||
words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
|
||||
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
|
||||
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
|
||||
word offsets, so segment timestamps and diarization also request word offsets there.
|
||||
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
|
||||
cross-provider speaker model. `speakers` is the exact number of speakers to label, which AssemblyAI (`speakers_expected`, the only route that
|
||||
accepts it) treats as a constraint rather than a hint.
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
|
||||
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
|
||||
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
|
||||
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
|
||||
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
|
||||
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
|
||||
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
|
||||
turns on, as AssemblyAI's utterances need speaker labels.
|
||||
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
|
||||
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
|
||||
ISO code on most routes (AssemblyAI's detection returns `en`), `english` from whisper-1. Deepgram and AssemblyAI
|
||||
assume English unless asked to detect, so a missing `language` enables their detection.
|
||||
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
|
||||
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
|
||||
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
|
||||
detection.
|
||||
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
|
||||
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
|
||||
turns (one part per turn), which join with a space.
|
||||
@@ -332,11 +337,12 @@ Settled rules:
|
||||
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
|
||||
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
|
||||
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
|
||||
with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
|
||||
with the realtime work in phase 5.
|
||||
|
||||
### `Generation` — shared async execution
|
||||
|
||||
@@ -361,6 +367,10 @@ Poll = { interval?: Duration; timeout?: Duration }
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
|
||||
|
||||
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
@@ -402,7 +412,7 @@ for await (const event of ai.llm.stream(request)) { … }
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. `AbortSignal` maps to interruption. Nothing in `src/*` except this entrypoint knows about promises.
|
||||
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
|
||||
|
||||
### Providers
|
||||
|
||||
@@ -414,7 +424,7 @@ implemented):
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ (queued) | ✓ | | | |
|
||||
@@ -467,7 +477,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
* hover imports and local values to see how the public API is typed.
|
||||
*/
|
||||
|
||||
const apiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Configure a provider. The configured facade records provider identity,
|
||||
// deployment options, authentication, and defaults. Per-modality selectors pick
|
||||
|
||||
@@ -383,7 +383,7 @@ const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
|
||||
})
|
||||
|
||||
const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
|
||||
Config.String(name)
|
||||
Config.string(name)
|
||||
.parse(provider)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
|
||||
@@ -102,7 +102,7 @@ export class Generation<Response> {
|
||||
return settled.pipe(
|
||||
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
|
||||
Effect.flatMap((generation) => generation.result()),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,20 +123,7 @@ export class Generation<Response> {
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
|
||||
// race and schedule another zero-delay poll.
|
||||
const refresh = Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
now >= deadline
|
||||
? this.timeoutError(timeout)
|
||||
: this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const refresh = within(this.refresh(), this.id, timeout, deadline)
|
||||
const schedule = this.schedule(options?.poll).pipe(
|
||||
Schedule.modifyDelay((meta) =>
|
||||
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
|
||||
@@ -157,15 +144,6 @@ export class Generation<Response> {
|
||||
return { type: "generation-progress", id: this.id, progress: this.progress }
|
||||
}
|
||||
|
||||
private timeoutError(timeout: Duration.Duration) {
|
||||
return new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private poll(poll: Poll | undefined) {
|
||||
return this.refresh().pipe(
|
||||
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
|
||||
@@ -177,12 +155,53 @@ export class Generation<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
/** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */
|
||||
export const resultEvents = <Response, A>(
|
||||
generation: Generation<Response>,
|
||||
expand: (response: Response) => ReadonlyArray<A>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<Observation | A, AIError> =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), expand))),
|
||||
): Stream.Stream<Observation | A, AIError> => {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(
|
||||
Stream.fromIterableEffect(
|
||||
within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe(
|
||||
Effect.map(expand),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast
|
||||
* request could otherwise win the zero-budget race and schedule another zero-delay poll.
|
||||
*/
|
||||
const within = <A>(effect: Effect.Effect<A, AIError>, id: string, timeout: Duration.Duration, deadline: number) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
now >= deadline
|
||||
? Effect.fail(timeoutError(id, timeout))
|
||||
: effect.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => Effect.fail(timeoutError(id, timeout)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const timeoutError = (id: string, timeout: Duration.Duration) =>
|
||||
new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ export { ImageClient } from "./image-client.js"
|
||||
export { Auth } from "./route/auth.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { ProviderPackage } from "./provider-package.js"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js"
|
||||
export { isContextOverflow, isContextOverflowFailure, isRetryable } from "./provider-error.js"
|
||||
export type {
|
||||
RouteLanguageModelInput,
|
||||
RouteRoutedLanguageModelInput,
|
||||
|
||||
@@ -42,7 +42,7 @@ export type GenerationHandle<Response> = Snapshot & {
|
||||
/** Serializable JSON; pass it back to `resume` from another process. */
|
||||
readonly token: unknown
|
||||
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
|
||||
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
|
||||
/** Status observations until the first terminal one, polling like `await`; abort throws `signal.reason`. */
|
||||
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
|
||||
/** The result without polling; fails when the generation has not completed. */
|
||||
readonly result: (options?: RunOptions) => Promise<Response>
|
||||
@@ -50,15 +50,16 @@ export type GenerationHandle<Response> = Snapshot & {
|
||||
readonly cancel: (options?: RunOptions) => Promise<void>
|
||||
}
|
||||
|
||||
// Fails with `signal.reason` so aborted calls reject and aborted streams throw like `fetch`: an `AbortError` by default.
|
||||
const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
signal === undefined
|
||||
? Effect.never
|
||||
: Effect.callback<void>((resume) => {
|
||||
: Effect.callback<never, unknown>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.void)
|
||||
resume(Effect.fail(signal.reason))
|
||||
return
|
||||
}
|
||||
const onAbort = () => resume(Effect.void)
|
||||
const onAbort = () => resume(Effect.fail(signal.reason))
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
|
||||
})
|
||||
@@ -68,14 +69,14 @@ export const make = (options: Options = {}) => {
|
||||
|
||||
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
|
||||
runtime.runPromise(effect, { signal: options?.signal })
|
||||
runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal)))
|
||||
|
||||
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
|
||||
Stream.toAsyncIterable(
|
||||
Stream.unwrap(
|
||||
runtime.contextEffect.pipe(
|
||||
Effect.map(
|
||||
(context): Stream.Stream<A, E> =>
|
||||
(context): Stream.Stream<A, unknown> =>
|
||||
stream.pipe(Stream.interruptWhen(abortEffect(options?.signal)), Stream.provideContext(context)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { SpeakerTurns } from "./utils/speaker-turns.js"
|
||||
|
||||
const route = MediaProtocol.identity({ id: "deepgram-transcription", name: "Deepgram", provider: "deepgram" })
|
||||
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
|
||||
@@ -115,16 +116,6 @@ const speaker = (value: number | undefined) => (value === undefined ? undefined
|
||||
|
||||
const wordText = (word: typeof Word.Type) => word.punctuated_word ?? word.word
|
||||
|
||||
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when its
|
||||
// words change speaker, so segments split each utterance at speaker changes.
|
||||
const speakerTurns = (words: ReadonlyArray<typeof Word.Type>) =>
|
||||
words.reduce<Array<Array<typeof Word.Type>>>((turns, word) => {
|
||||
const last = turns.at(-1)
|
||||
if (last === undefined || last[0].speaker !== word.speaker) return [...turns, [word]]
|
||||
last.push(word)
|
||||
return turns
|
||||
}, [])
|
||||
|
||||
const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
@@ -136,6 +127,8 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
|
||||
const requestID = output.value.metadata?.request_id
|
||||
return new TranscriptionResponse({
|
||||
text: alternative.transcript,
|
||||
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when
|
||||
// its words change speaker, so segments split each utterance at speaker changes.
|
||||
segments: output.value.results.utterances?.flatMap((utterance) =>
|
||||
utterance.words === undefined || utterance.words.length === 0
|
||||
? [
|
||||
@@ -146,7 +139,7 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
|
||||
speaker: speaker(utterance.speaker),
|
||||
},
|
||||
]
|
||||
: speakerTurns(utterance.words).map((turn) => ({
|
||||
: SpeakerTurns.group(utterance.words, (word) => word.speaker).map((turn) => ({
|
||||
text: turn.map(wordText).join(" "),
|
||||
startSeconds: turn[0].start,
|
||||
endSeconds: turn[turn.length - 1].end,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
|
||||
import { mediaTypeExtension } from "../utils/media-type.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
import { SpeakerTurns } from "./utils/speaker-turns.js"
|
||||
|
||||
const route = MediaProtocol.identity({
|
||||
id: "elevenlabs-transcription",
|
||||
name: "ElevenLabs Transcription",
|
||||
provider: "elevenlabs",
|
||||
})
|
||||
export const DEFAULT_BASE_URL = "https://api.elevenlabs.io"
|
||||
export const PATH = "/v1/speech-to-text"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ElevenLabsTranscriptionOptions = {
|
||||
readonly tag_audio_events?: boolean
|
||||
readonly timestamps_granularity?: OpenString<"none" | "word" | "character">
|
||||
readonly diarization_threshold?: number
|
||||
readonly file_format?: OpenString<"pcm_s16le_16" | "other">
|
||||
readonly temperature?: number
|
||||
readonly seed?: number
|
||||
readonly keyterms?: ReadonlyArray<string>
|
||||
readonly no_verbatim?: boolean
|
||||
readonly detect_speaker_roles?: boolean
|
||||
readonly use_speaker_library?: boolean
|
||||
readonly entity_detection?: string | ReadonlyArray<string>
|
||||
readonly entity_redaction?: string | ReadonlyArray<string>
|
||||
readonly entity_redaction_mode?: OpenString<"redacted" | "entity_type" | "enumerated_entity_type">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<ElevenLabsTranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `type` is `word`, `spacing` (the whitespace between words), or `audio_event` (`(laughter)`). */
|
||||
const Token = Schema.Struct({
|
||||
text: Schema.String,
|
||||
type: Schema.String,
|
||||
start: optionalNull(Schema.Number),
|
||||
end: optionalNull(Schema.Number),
|
||||
speaker_id: optionalNull(Schema.String),
|
||||
logprob: optionalNull(Schema.Number),
|
||||
})
|
||||
type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const Transcript = Schema.Struct({
|
||||
language_code: optionalNull(Schema.String),
|
||||
text: Schema.String,
|
||||
words: optionalNull(Schema.Array(Token)),
|
||||
transcription_id: optionalNull(Schema.String),
|
||||
audio_duration_secs: optionalNull(Schema.Number),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Speaker turns are the only segments ElevenLabs can produce, and `num_speakers` only applies to diarization. */
|
||||
const diarizes = (request: Request) =>
|
||||
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
|
||||
|
||||
const RESERVED_FORM_FIELDS = new Set([
|
||||
"file",
|
||||
"cloud_storage_url",
|
||||
"source_url",
|
||||
"model_id",
|
||||
"language_code",
|
||||
"diarize",
|
||||
"num_speakers",
|
||||
])
|
||||
|
||||
const validate = (request: Request, overlay: Record<string, unknown>) => {
|
||||
// Webhook requests return 202 with no transcript; the result arrives at a configured webhook instead.
|
||||
if (overlay.webhook === true)
|
||||
return Effect.fail(route.unsupported("transcription.webhook", `${route.name} does not deliver to webhooks`))
|
||||
// Separate multichannel output replaces the transcript with one transcript per channel.
|
||||
if (overlay.use_multi_channel === true && overlay.multichannel_output_style !== "combined")
|
||||
return Effect.fail(
|
||||
route.unsupported(
|
||||
"transcription.multichannel",
|
||||
`${route.name} returns a single transcript; set multichannel_output_style: "combined" to merge channels`,
|
||||
),
|
||||
)
|
||||
if (overlay.timestamps_granularity === "none" && (request.timestamps === "word" || diarizes(request)))
|
||||
return Effect.fail(
|
||||
route.unsupported(
|
||||
"media.timestamps",
|
||||
`${route.name} cannot return word timestamps or speaker turns with timestamps_granularity: "none"`,
|
||||
),
|
||||
)
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("ElevenLabsTranscription.fromRequest")(function* (request: Request) {
|
||||
const overlay = mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}
|
||||
yield* validate(request, overlay)
|
||||
const form = new FormData()
|
||||
const url = ProviderShared.mediaUrl(request.audio)
|
||||
if (url === undefined) {
|
||||
const extension = mediaTypeExtension(request.audio.mediaType)
|
||||
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
|
||||
form.append(
|
||||
"file",
|
||||
MediaInput.blob(audio, request.audio.mediaType),
|
||||
extension === undefined ? "audio" : `audio.${extension}`,
|
||||
)
|
||||
}
|
||||
MediaInput.appendFields(
|
||||
form,
|
||||
{
|
||||
model_id: request.model.id,
|
||||
// `cloud_storage_url` is deprecated in favor of `source_url`, which accepts any hosted audio or video URL.
|
||||
source_url: url,
|
||||
language_code: request.language,
|
||||
diarize: diarizes(request) ? true : undefined,
|
||||
num_speakers: request.speakers,
|
||||
},
|
||||
{ overlay, reserved: RESERVED_FORM_FIELDS, repeatArrays: "key" },
|
||||
)
|
||||
return MediaProtocol.multipart(form)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeTranscript = route.decodeJson(Transcript)
|
||||
|
||||
type TimedWord = Token & { readonly start: number; readonly end: number }
|
||||
|
||||
const isTimedWord = (token: Token): token is TimedWord =>
|
||||
token.type === "word" && typeof token.start === "number" && typeof token.end === "number"
|
||||
|
||||
/** Turn text keeps the provider's own spacing tokens, so languages written without spaces are not re-spaced. */
|
||||
const speakerTurns = (tokens: ReadonlyArray<Token>) =>
|
||||
SpeakerTurns.group(
|
||||
tokens.filter((token) => token.type === "word" || token.type === "spacing"),
|
||||
(token) => token.speaker_id,
|
||||
).flatMap((turn) => {
|
||||
const words = turn.filter(isTimedWord)
|
||||
if (words.length === 0) return []
|
||||
return [
|
||||
{
|
||||
text: turn
|
||||
.map((token) => token.text)
|
||||
.join("")
|
||||
.trim(),
|
||||
startSeconds: words[0].start,
|
||||
endSeconds: words[words.length - 1].end,
|
||||
speaker: turn[0].speaker_id ?? undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const decodeResponse = Effect.fn("ElevenLabsTranscription.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.DecodeContext<Request>,
|
||||
) {
|
||||
const output = yield* decodeTranscript(response)
|
||||
const transcript = output.value
|
||||
const tokens = transcript.words ?? []
|
||||
const duration = transcript.audio_duration_secs ?? undefined
|
||||
const transcriptionID = transcript.transcription_id ?? undefined
|
||||
return new TranscriptionResponse({
|
||||
text: transcript.text,
|
||||
segments: diarizes(context.request) ? speakerTurns(tokens) : undefined,
|
||||
words: tokens.filter(isTimedWord).map((word) => ({
|
||||
text: word.text,
|
||||
startSeconds: word.start,
|
||||
endSeconds: word.end,
|
||||
speaker: word.speaker_id ?? undefined,
|
||||
confidence: typeof word.logprob === "number" ? Math.exp(word.logprob) : undefined,
|
||||
})),
|
||||
language: transcript.language_code?.toLowerCase(),
|
||||
durationSeconds: duration,
|
||||
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
|
||||
providerMetadata: transcriptionID === undefined ? undefined : { elevenlabs: { transcriptionId: transcriptionID } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>(route, {
|
||||
unsupported: ["prompt"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<ElevenLabsTranscriptionOptions>(
|
||||
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
|
||||
input,
|
||||
)
|
||||
|
||||
export const ElevenLabsTranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -36,7 +36,9 @@ const StartResponse = Schema.Struct({ name: Schema.String })
|
||||
|
||||
const Operation = Schema.Struct({
|
||||
done: Schema.optional(Schema.Boolean),
|
||||
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
||||
error: Schema.optional(
|
||||
Schema.Struct({ code: Schema.optional(Schema.Number), message: Schema.optional(Schema.String) }),
|
||||
),
|
||||
response: Schema.optional(
|
||||
Schema.Struct({
|
||||
generateVideoResponse: Schema.optional(
|
||||
@@ -60,6 +62,16 @@ const Operation = Schema.Struct({
|
||||
metadata: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
// Operation errors are `google.rpc.Status`; unlisted codes (INTERNAL, UNAVAILABLE, ...) are provider-side.
|
||||
const FAILURE = {
|
||||
3: "InvalidRequest", // INVALID_ARGUMENT
|
||||
7: "Authentication", // PERMISSION_DENIED
|
||||
8: "RateLimit", // RESOURCE_EXHAUSTED
|
||||
9: "InvalidRequest", // FAILED_PRECONDITION
|
||||
11: "InvalidRequest", // OUT_OF_RANGE
|
||||
16: "Authentication", // UNAUTHENTICATED
|
||||
} as const satisfies Record<number, MediaProtocol.Failure>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,6 +166,7 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${route.name} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
|
||||
MediaProtocol.failure(FAILURE, operation.error?.code),
|
||||
)
|
||||
const generated = operation.response?.generateVideoResponse
|
||||
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
|
||||
|
||||
@@ -64,6 +64,14 @@ const OpenAIChatTool = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
|
||||
// Gemini's OpenAI-compatible surface carries thought signatures in tool call
|
||||
// `extra_content` and rejects replayed parallel calls without them:
|
||||
// https://ai.google.dev/gemini-api/docs/thinking#signatures
|
||||
const ExtraContent = Schema.Struct({
|
||||
google: Schema.Struct({ thought_signature: Schema.String }),
|
||||
})
|
||||
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
|
||||
|
||||
const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.tag("function"),
|
||||
@@ -71,6 +79,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}),
|
||||
extra_content: Schema.optional(ExtraContent),
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
@@ -112,12 +121,6 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
|
||||
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
|
||||
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
|
||||
|
||||
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||
// https://github.com/vercel/ai/issues/11590
|
||||
// https://github.com/vercel/ai/pull/11745
|
||||
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
@@ -242,6 +245,7 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||
extra_content: optionalNull(Schema.Unknown),
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
@@ -294,6 +298,7 @@ interface PendingToolDelta {
|
||||
readonly id?: string
|
||||
readonly name?: string
|
||||
readonly input: string
|
||||
readonly extraContent?: Schema.Schema.Type<typeof ExtraContent>
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
@@ -347,13 +352,17 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
): OpenAIChatAssistantToolCall => ({
|
||||
id: options.toolCallID?.(part.id) ?? part.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
|
||||
},
|
||||
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
|
||||
@@ -721,7 +730,9 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
|
||||
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
|
||||
const isOpencode = p === "opencode" || url.includes("opencode.ai")
|
||||
const isGemini = url.includes("generativelanguage.googleapis.com")
|
||||
const isNonStandard =
|
||||
isGemini ||
|
||||
isNvidia ||
|
||||
isCerebras ||
|
||||
isXai ||
|
||||
@@ -1114,12 +1125,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||
const extraContent = pending?.extraContent ?? decodeExtraContent(tool.extra_content)
|
||||
latestToolIndex = index
|
||||
nextToolIndex = Math.max(nextToolIndex, index + 1)
|
||||
if (!current && (!id || !name)) {
|
||||
pendingTools = {
|
||||
...pendingTools,
|
||||
[index]: { id: id || undefined, name: name || undefined, input: text },
|
||||
[index]: { id: id || undefined, name: name || undefined, input: text, extraContent },
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1131,7 +1143,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
ADAPTER,
|
||||
tools,
|
||||
index,
|
||||
{ id: id || undefined, name: name || undefined, text },
|
||||
{
|
||||
id: id || undefined,
|
||||
name: name || undefined,
|
||||
text,
|
||||
providerMetadata: extraContent && { [state.providerMetadataKey]: { extraContent } },
|
||||
},
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result))
|
||||
|
||||
@@ -193,7 +193,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
|
||||
{
|
||||
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
|
||||
reserved: RESERVED_FORM_FIELDS,
|
||||
repeatArrays: true,
|
||||
repeatArrays: "key[]",
|
||||
},
|
||||
)
|
||||
return MediaProtocol.multipart(form)
|
||||
|
||||
@@ -137,7 +137,12 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
|
||||
const message = `${route.name} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
|
||||
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
|
||||
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
|
||||
return yield* output.ended("failed", message)
|
||||
// ASSET.INVALID rejects the caller's input media; Runway documents it as not retryable.
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
message,
|
||||
code !== undefined && /^ASSET\.INVALID(\.|$)/.test(code) ? "InvalidRequest" : "ProviderInternal",
|
||||
)
|
||||
}
|
||||
if (status === "cancelled")
|
||||
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
|
||||
|
||||
@@ -71,8 +71,9 @@ export const imageOutput = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated `key[]` parts with
|
||||
* `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
|
||||
* Append multipart text fields: strings as-is, other values as JSON, or scalar arrays as one part per item with
|
||||
* `repeatArrays`, named `key[]` or `key`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace
|
||||
* route-owned fields.
|
||||
*/
|
||||
export const appendFields = (
|
||||
form: FormData,
|
||||
@@ -80,13 +81,13 @@ export const appendFields = (
|
||||
options: {
|
||||
readonly overlay?: Record<string, unknown>
|
||||
readonly reserved: ReadonlySet<string>
|
||||
readonly repeatArrays?: true
|
||||
readonly repeatArrays?: "key[]" | "key"
|
||||
},
|
||||
) => {
|
||||
const overlay = Object.entries(options.overlay ?? {}).filter(([key]) => !options.reserved.has(key))
|
||||
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value) && options.repeatArrays)
|
||||
return value.forEach((item) => form.append(`${key}[]`, String(item)))
|
||||
if (Array.isArray(value) && value.every(isScalar) && options.repeatArrays !== undefined)
|
||||
return value.forEach((item) => form.append(options.repeatArrays === "key[]" ? `${key}[]` : key, String(item)))
|
||||
form.append(key, typeof value === "string" ? value : encodeJson(value))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Split an ordered token list into runs of consecutive tokens with the same speaker. */
|
||||
export const group = <Item>(items: ReadonlyArray<Item>, speaker: (item: Item) => unknown) =>
|
||||
items.reduce<Array<Array<Item>>>((turns, item) => {
|
||||
const last = turns.at(-1)
|
||||
if (last === undefined || speaker(last[0]) !== speaker(item)) return [...turns, [item]]
|
||||
last.push(item)
|
||||
return turns
|
||||
}, [])
|
||||
|
||||
export * as SpeakerTurns from "./speaker-turns.js"
|
||||
@@ -147,7 +147,12 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
route: string,
|
||||
tools: State<K>,
|
||||
key: K,
|
||||
delta: { readonly id?: string; readonly name?: string; readonly text: string },
|
||||
delta: {
|
||||
readonly id?: string
|
||||
readonly name?: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | AIError => {
|
||||
const current = tools[key]
|
||||
@@ -161,7 +166,7 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
namespace: current?.namespace,
|
||||
input: `${current?.input ?? ""}${delta.text}`,
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
providerMetadata: current?.providerMetadata ?? delta.providerMetadata,
|
||||
}
|
||||
if (current && delta.text.length === 0 && current.id === id && current.name === name)
|
||||
return { tools, tool: current, events: [] }
|
||||
|
||||
@@ -65,6 +65,13 @@ const STATUS = {
|
||||
expired: "expired",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// Documented video error codes; `service_unavailable`, `internal_error`, and unknown codes are provider-side.
|
||||
const FAILURE = {
|
||||
invalid_argument: "InvalidRequest",
|
||||
failed_precondition: "InvalidRequest",
|
||||
permission_denied: "Authentication",
|
||||
} as const satisfies Record<string, MediaProtocol.Failure>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,6 +150,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${route.name} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
|
||||
MediaProtocol.failure(FAILURE, code),
|
||||
)
|
||||
}
|
||||
if (status !== "completed")
|
||||
|
||||
@@ -58,6 +58,47 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
/**
|
||||
* Whether a failed call may succeed when sent again: rate limits, provider-side failures, transport failures that did
|
||||
* not deliver an accepted write, and unrecognized failures. Callers decide which calls are safe to repeat.
|
||||
*/
|
||||
export const isRetryable = (error: AIError) => {
|
||||
const override = error.reason.http?.headers["x-should-retry"]
|
||||
if (override === "true") return true
|
||||
if (override === "false") return false
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
|
||||
// Read failures can still recover; the caller chooses retry versus continuation from durable output.
|
||||
case "Transport":
|
||||
return (
|
||||
error.reason.delivery !== "rejected" &&
|
||||
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
|
||||
)
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
// deterministic evidence, and transient failures are exactly the ones
|
||||
// that arrive in shapes no classifier anticipates.
|
||||
case "UnknownProvider":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidRequest":
|
||||
case "UnsupportedOperation":
|
||||
case "NoRoute":
|
||||
case "Timeout":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ElevenLabsSpeech } from "../protocols/elevenlabs-speech.js"
|
||||
import { ElevenLabsTranscription } from "../protocols/elevenlabs-transcription.js"
|
||||
|
||||
export type { ElevenLabsOutputFormat, ElevenLabsSpeechOptions } from "../protocols/elevenlabs-speech.js"
|
||||
export type { ElevenLabsTranscriptionOptions } from "../protocols/elevenlabs-transcription.js"
|
||||
|
||||
export const id = ProviderID.make("elevenlabs")
|
||||
|
||||
@@ -24,12 +26,15 @@ const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
export const configure = (input: Config = {}) => {
|
||||
const media = MediaRoute.deployment(input, auth(input))
|
||||
const speech = (modelID: string | ModelID) => ElevenLabsSpeech.model({ ...media, id: modelID })
|
||||
const transcription = (modelID: string | ModelID) => ElevenLabsTranscription.model({ ...media, id: modelID })
|
||||
return {
|
||||
id,
|
||||
speech,
|
||||
transcription,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const speech = provider.speech
|
||||
export const transcription = provider.transcription
|
||||
|
||||
@@ -95,7 +95,7 @@ export const optional = (secret: Secret | undefined, source = "optional value")
|
||||
export const config = (name: string) =>
|
||||
credential(
|
||||
Effect.gen(function* () {
|
||||
const secret = yield* Config.option(Config.Redacted(name))
|
||||
const secret = yield* Config.option(Config.redacted(name))
|
||||
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
|
||||
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
|
||||
}),
|
||||
|
||||
@@ -5,12 +5,14 @@ import { Media } from "../media.js"
|
||||
import type { AuthInput } from "./auth.js"
|
||||
import {
|
||||
AIError,
|
||||
AuthenticationError,
|
||||
ContentPolicyError,
|
||||
HttpContext,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ProviderID,
|
||||
ProviderInternalError,
|
||||
RateLimitError,
|
||||
UnsupportedOperationError,
|
||||
} from "../schema/index.js"
|
||||
|
||||
@@ -188,6 +190,16 @@ export const stream = <Request, Event, Frame, State>(
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Reasons a provider can report for a `failed` generation; anything it does not classify is `ProviderInternal`. */
|
||||
const FAILURES = {
|
||||
InvalidRequest: InvalidRequestError,
|
||||
Authentication: AuthenticationError,
|
||||
RateLimit: RateLimitError,
|
||||
ProviderInternal: ProviderInternalError,
|
||||
}
|
||||
|
||||
export type Failure = keyof typeof FAILURES
|
||||
|
||||
const context = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
|
||||
|
||||
@@ -199,9 +211,10 @@ export const identity = (input: { readonly id: string; readonly name: string; re
|
||||
|
||||
/**
|
||||
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
|
||||
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
|
||||
* provider-side, `cancelled`/`expired` mean the result will never exist); `pending` is a `result()` read before the
|
||||
* generation finished, which is caller misuse; `contentPolicy` is a moderated result.
|
||||
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed`
|
||||
* carries the provider's classification, defaulting to `ProviderInternal`; `cancelled`/`expired` mean the result
|
||||
* will never exist); `pending` is a `result()` read before the generation finished, which is caller misuse;
|
||||
* `contentPolicy` is a moderated result.
|
||||
*/
|
||||
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
|
||||
const http = context(response)
|
||||
@@ -223,11 +236,15 @@ export const identity = (input: { readonly id: string; readonly name: string; re
|
||||
http,
|
||||
invalid: (message: string, cause?: unknown) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, http, cause }) }),
|
||||
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
|
||||
ended: (
|
||||
status: Exclude<Status, "queued" | "running" | "completed">,
|
||||
message: string,
|
||||
failure: Failure = "ProviderInternal",
|
||||
) =>
|
||||
new AIError({
|
||||
reason:
|
||||
status === "failed"
|
||||
? new ProviderInternalError({ message, body, http })
|
||||
? new FAILURES[failure]({ message, body, http })
|
||||
: new InvalidRequestError({ message, body, http }),
|
||||
}),
|
||||
pending: (id: string) =>
|
||||
@@ -303,6 +320,10 @@ export const status = <Table extends Record<string, Status>>(
|
||||
return Effect.succeed(table[raw])
|
||||
}
|
||||
|
||||
/** Map a provider error code through the protocol's table; missing or unmapped codes are `ProviderInternal`. */
|
||||
export const failure = (table: Readonly<Record<string, Failure>>, code: string | number | undefined): Failure =>
|
||||
code !== undefined && Object.hasOwn(table, code) ? table[code] : "ProviderInternal"
|
||||
|
||||
/** A `url` asset whose provider-declared retention window starts now. */
|
||||
export const expiringUrl = (url: string, retention: Duration.Duration, options?: Parameters<typeof Media.url>[1]) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "./auth.js"
|
||||
import { Endpoint } from "./endpoint.js"
|
||||
@@ -7,6 +7,7 @@ import { RequestExecutor } from "./executor.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation, isTerminal } from "../generation.js"
|
||||
import type { Media } from "../media.js"
|
||||
import { isRetryable } from "../provider-error.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
@@ -137,6 +138,32 @@ export const inline = <Request extends MediaRequest, Response>(
|
||||
}
|
||||
}
|
||||
|
||||
const READ_RETRY_MAX_DELAY = Duration.seconds(30)
|
||||
|
||||
/**
|
||||
* Status and result reads retry transient failures; `start` and `cancel` never do. Gaps grow exponentially from 1s,
|
||||
* jittered, up to 30s each, for at most 8 retries (about two minutes when every attempt fails), so a direct
|
||||
* `Generation.result()` stays bounded; `await` and `events` also cut retries off at `poll.timeout`. A provider
|
||||
* `retryAfterMs` raises the gap, still capped at 30s.
|
||||
*/
|
||||
const READ_RETRY = Schedule.max([
|
||||
Schedule.min([Schedule.exponential("1 second"), Schedule.spaced(READ_RETRY_MAX_DELAY)]),
|
||||
Schedule.recurs(8),
|
||||
]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<AIError>(),
|
||||
Schedule.modifyDelay(({ input, duration }) =>
|
||||
Effect.succeed(
|
||||
Duration.min(
|
||||
input.reason._tag === "RateLimit" || input.reason._tag === "ProviderInternal"
|
||||
? Duration.max(duration, Duration.millis(input.reason.retryAfterMs ?? 0))
|
||||
: duration,
|
||||
READ_RETRY_MAX_DELAY,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Compose a queued media protocol the same way, adding `start`/`resume` handles whose polls reuse the route's auth,
|
||||
* deployment headers, and (for `start`) the request's `http` overlay. The token is decoded once at the boundary and
|
||||
@@ -154,6 +181,8 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
|
||||
const materialize = (asset: Media.Asset) =>
|
||||
asset.materialize().pipe(Effect.provideService(RequestExecutorService, { execute }))
|
||||
// Only the GET exchange retries: a decoded terminal failure (`output.ended`) can be a `ProviderInternal` too, and
|
||||
// re-reading it would spin until the caller's deadline.
|
||||
const poll = <A>(operation: {
|
||||
readonly path: (token: Token) => string
|
||||
readonly decode: (
|
||||
@@ -161,9 +190,10 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) => Effect.Effect<A, AIError>
|
||||
}) =>
|
||||
transport
|
||||
.call("GET", operation.path(token), http, execute)
|
||||
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
|
||||
transport.call("GET", operation.path(token), http, execute).pipe(
|
||||
Effect.retry({ schedule: READ_RETRY, while: isRetryable }),
|
||||
Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })),
|
||||
)
|
||||
const status = poll(protocol.status)
|
||||
const cancel = protocol.cancel
|
||||
const send =
|
||||
|
||||
+2
-28
@@ -6,7 +6,6 @@ import type {
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema/index.js"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
|
||||
import { isRecord } from "./utils/record.js"
|
||||
|
||||
/**
|
||||
* Schema constraint for tool parameters / success values: no decoding or
|
||||
@@ -201,7 +200,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
description: config.description,
|
||||
inputSchema: toInputJsonSchema(config.parameters),
|
||||
inputSchema: toJsonSchema(config.parameters),
|
||||
outputSchema: toJsonSchema(config.success),
|
||||
}),
|
||||
}
|
||||
@@ -231,33 +230,8 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
|
||||
}),
|
||||
)
|
||||
|
||||
const toInputJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
// Effect represents Struct({}) as any non-null value; tool parameters must
|
||||
// instead describe an object. Inline named empty inputs so providers such as
|
||||
// Gemini can recognize a no-argument tool, while retaining schema annotations.
|
||||
if (
|
||||
schema.ast._tag !== "Objects" ||
|
||||
schema.ast.propertySignatures.length !== 0 ||
|
||||
schema.ast.indexSignatures.length !== 0 ||
|
||||
schema.ast.encoding !== undefined ||
|
||||
schema.ast.checks !== undefined
|
||||
)
|
||||
return toJsonSchema(schema)
|
||||
|
||||
const json = Schema.toJsonSchemaDocument(schema, {
|
||||
onExcessProperty: "error",
|
||||
referencePolicy: () => undefined,
|
||||
}).schema
|
||||
if (isRecord(json.not) && json.not.type === "null" && Object.keys(json.not).length === 1) {
|
||||
const normalized: JsonSchema.JsonSchema = { type: "object", properties: {}, additionalProperties: false, ...json }
|
||||
delete normalized.not
|
||||
return normalized
|
||||
}
|
||||
return toJsonSchema(schema)
|
||||
}
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
if (Object.keys(document.definitions).length === 0) return document.schema
|
||||
return { ...document.schema, $defs: document.definitions }
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export type TranscriptionRequestInput<Model extends TranscriptionModel = Transcr
|
||||
// Response and events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, or a known speaker name). */
|
||||
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, `speaker_0`, or a known speaker name). */
|
||||
export const TranscriptionSegment = Schema.Struct({
|
||||
text: Schema.String,
|
||||
startSeconds: Schema.Number,
|
||||
|
||||
@@ -30,7 +30,7 @@ type LanguageModel = {
|
||||
declare const auth: Auth.Definition
|
||||
declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
|
||||
declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
|
||||
const configApiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
|
||||
|
||||
|
||||
@@ -197,6 +197,11 @@ describe("public exports", () => {
|
||||
expect(Google.configure({ apiKey: "fixture" }).transcription("gemini-3.5-transcribe").route.kind).toBe("stream")
|
||||
expect(Deepgram.configure({ apiKey: "fixture" }).transcription("nova-3").route.kind).toBe("inline")
|
||||
expect(AssemblyAI.configure({ apiKey: "fixture" }).transcription("universal-3-5-pro").route.kind).toBe("queued")
|
||||
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.id).toBe(
|
||||
"elevenlabs-transcription",
|
||||
)
|
||||
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.kind).toBe("inline")
|
||||
expect(ElevenLabs.provider.transcription).toBe(ElevenLabs.transcription)
|
||||
})
|
||||
|
||||
test("protocol barrels expose supported low-level routes", () => {
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:elevenlabs-transcription",
|
||||
"provider:elevenlabs",
|
||||
"protocol:elevenlabs-transcription"
|
||||
],
|
||||
"name": "elevenlabs-transcription/groups-diarized-words-into-speaker-turns",
|
||||
"recordedAt": "2026-09-27T09:35:28.265Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.elevenlabs.io/v1/speech-to-text",
|
||||
"headers": {
|
||||
"content-type": "multipart/form-data; boundary=----WebKitFormBoundary356bdc14864a477dbacbfcf60d1ecceb"
|
||||
},
|
||||
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"diarize\"\r\n\r\ntrue\r\n--BOUNDARY--\r\n"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"language_code\":\"eng\",\"language_probability\":0.9495430588722229,\"text\":\"Did the release ship? Yes, it shipped this morning\",\"words\":[{\"text\":\"Did\",\"start\":0.34,\"end\":0.44,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.7881377516459906e-6},{\"text\":\" \",\"start\":0.44,\"end\":0.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\"the\",\"start\":0.48,\"end\":0.56,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\" \",\"start\":0.56,\"end\":0.6,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\"release\",\"start\":0.6,\"end\":0.92,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\" \",\"start\":0.92,\"end\":0.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-8.344646857949556e-7},{\"text\":\"ship?\",\"start\":0.94,\"end\":1.26,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.414704032271402e-6},{\"text\":\" \",\"start\":1.26,\"end\":1.26,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-0.0009363081189803779},{\"text\":\"Yes,\",\"start\":1.68,\"end\":2.02,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.003542040009030245},{\"text\":\" \",\"start\":2.02,\"end\":2.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\"it\",\"start\":2.48,\"end\":2.62,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\" \",\"start\":2.62,\"end\":2.64,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\"shipped\",\"start\":2.66,\"end\":2.9,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\" \",\"start\":2.9,\"end\":2.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\"this\",\"start\":2.94,\"end\":3.12,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\" \",\"start\":3.12,\"end\":3.18,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7},{\"text\":\"morning\",\"start\":3.18,\"end\":3.5,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7}],\"transcription_id\":\"cs3I2282TH8hjw12brNg\",\"audio_duration_secs\":3.5526875}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:elevenlabs-transcription",
|
||||
"provider:elevenlabs",
|
||||
"protocol:elevenlabs-transcription"
|
||||
],
|
||||
"name": "elevenlabs-transcription/transcribes-audio-with-word-timestamps",
|
||||
"recordedAt": "2026-09-27T09:35:27.686Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.elevenlabs.io/v1/speech-to-text",
|
||||
"headers": {
|
||||
"content-type": "multipart/form-data; boundary=----WebKitFormBoundarye2be7b31e94441bbbeb35a9c890a9d74"
|
||||
},
|
||||
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000014781842764932662},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-3.814689989667386e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-3.814689989667386e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.018268775194883347},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1251817401498556}],\"transcription_id\":\"D4VfnANM2ArCHTujIb9q\",\"audio_duration_secs\":1.54125}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.elevenlabs.io/v1/speech-to-text",
|
||||
"headers": {
|
||||
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryfb80d0e44d9e44d299416ed546a04056"
|
||||
},
|
||||
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000023007127310847864},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-2.3841830625315197e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-2.3841830625315197e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.008306833915412426},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1075385226868093}],\"transcription_id\":\"SkYplzfq1DW8Ae3bWnoy\",\"audio_duration_secs\":1.54125}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "gemini-3.8-flash",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:google",
|
||||
"protocol:openai-chat",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation"
|
||||
],
|
||||
"name": "gemini-parallel-tool-signatures",
|
||||
"recordedAt": "2026-09-28T03:12:05.083Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}},\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723181\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":16,\"prompt_tokens\":74,\"total_tokens\":132}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723184\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565124,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_723181\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}}},{\"id\":\"call_723184\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_723181\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_723184\",\"content\":\"{\\\"temperature\\\":0,\\\"condition\\\":\\\"unknown\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"It is currently sunny and 22°C in Paris,\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":13,\"prompt_tokens\":150,\"total_tokens\":226}}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" while Tokyo is 0°C.\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":150,\"total_tokens\":234}}\n\ndata: {\"choices\":[{\"delta\":{\"extra_content\":{\"google\":{\"thought_signature\":\"EpcDCpQDAWkUfRM325TAUtfFiOeQIEWn/TsCU9oi2js4VPHFeeLfAu+2k8PJm0fN/OaF0y4ovau7S9QIAsuOPI2w2aIyQ2kMGj1XvUyRTvz30DOZgtq1km6W6YGzZyyTCNSeBcpwtJtziHZVVWq9xEI/HHB8Ta1Ot215xnFyDL7iUGEwgGu45/mInpk+SOCYBy9biDddpDxcDi14BGoleArY9XEFAzYLxXssl7HMWjpfee5095im7gD125Nripq1Jf3nGY/2TxqjgQAdJpQybwct63p74O1szGHQxrkBt7AwphDgbOWtLpUP/QJBRdl8qhrozqRe611NQ6V5lMSwpO7OhQ/IDRtWMwOyrrKblZfmMnnPl2/9xDfZRsYnfmWq+7PeAptJl1cDRlMBKhj5iRn31xvN43EiuvWwWPsSndiWvxrMvVBd89TR4u0+z0pYCYZcsFkYKFlPA7pZsdnh6CON24AA5WUkO4dQNoqYdik6aO5hE9jLHG5FHTdr0W69qgPNozbxnO0ptRcOtkGpB9xYUVoTxcSXXZE=\"}},\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":192,\"total_tokens\":276}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,7 +22,8 @@ const chatBody = sseEvents(
|
||||
/**
|
||||
* Executor layer that answers chat completions with SSE text, image generations with one base64 PNG, Runway video
|
||||
* tasks with a queued submission that succeeds on the second poll, speech with raw audio or SSE audio deltas, OpenAI
|
||||
* transcription with JSON or SSE text deltas, and AssemblyAI transcripts that complete on the first poll.
|
||||
* transcription with JSON or SSE text deltas, AssemblyAI transcripts that complete on the first poll, and `slow.test`
|
||||
* chat completions that send one text delta and never finish.
|
||||
*/
|
||||
const executor = (seen: Array<string>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
@@ -55,6 +56,18 @@ const executor = (seen: Array<string>) =>
|
||||
output: "https://replicate.test/a.webp",
|
||||
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
||||
})
|
||||
if (web.url.startsWith("https://slow.test"))
|
||||
return input.respond(
|
||||
new ReadableStream({
|
||||
start: (controller) =>
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n\n`,
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
if (web.url.endsWith("/chat/completions"))
|
||||
return input.respond(chatBody, { headers: { "content-type": "text/event-stream" } })
|
||||
if (web.url.endsWith("/audio/speech"))
|
||||
@@ -304,6 +317,77 @@ describe("AI promise client", () => {
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("aborted calls reject and aborted streams throw with the signal's reason", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
const reason = new Error("mine")
|
||||
|
||||
const rejected = await ai.run(Effect.never, { signal: aborted.signal }).catch((error: unknown) => error)
|
||||
expect(rejected).toBe(aborted.signal.reason)
|
||||
expect(rejected).toMatchObject({ name: "AbortError" })
|
||||
|
||||
const inFlight = new AbortController()
|
||||
setTimeout(() => inFlight.abort(reason), 10)
|
||||
expect(
|
||||
await ai.llm
|
||||
.generate({ model: slow, prompt: "Hello" }, { signal: inFlight.signal })
|
||||
.catch((error: unknown) => error),
|
||||
).toBe(reason)
|
||||
|
||||
const preAborted = await Array.fromAsync(
|
||||
ai.speech.stream({ model: openai.speech("gpt-4o-mini-tts"), text: "Hello" }, { signal: aborted.signal }),
|
||||
).catch((error: unknown) => error)
|
||||
expect(preAborted).toBe(aborted.signal.reason)
|
||||
expect(preAborted).toMatchObject({ name: "AbortError" })
|
||||
|
||||
const midStream = new AbortController()
|
||||
const deltas: Array<string> = []
|
||||
const midStreamFailure = await Array.fromAsync(
|
||||
ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: midStream.signal }),
|
||||
(event) => {
|
||||
if (!LLMEvent.is.textDelta(event)) return
|
||||
deltas.push(event.text)
|
||||
midStream.abort()
|
||||
},
|
||||
).catch((error: unknown) => error)
|
||||
expect(deltas).toEqual(["Hello"])
|
||||
expect(midStreamFailure).toBe(midStream.signal.reason)
|
||||
expect(midStreamFailure).toMatchObject({ name: "AbortError" })
|
||||
|
||||
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
|
||||
const generation = await ai.video.start({ model, prompt: "A kite" })
|
||||
const polling = new AbortController()
|
||||
const events: Array<string> = []
|
||||
const eventsFailure = await Array.fromAsync(
|
||||
generation.events({ poll: { interval: 60_000 }, signal: polling.signal }),
|
||||
(event) => {
|
||||
events.push(event.type)
|
||||
polling.abort(reason)
|
||||
},
|
||||
).catch((error: unknown) => error)
|
||||
expect(events).toEqual(["generation-progress"])
|
||||
expect(eventsFailure).toBe(reason)
|
||||
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("breaking out of an abortable stream cleans up without throwing", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
|
||||
const controller = new AbortController()
|
||||
const deltas: Array<string> = []
|
||||
for await (const event of ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: controller.signal })) {
|
||||
if (!LLMEvent.is.textDelta(event)) continue
|
||||
deltas.push(event.text)
|
||||
break
|
||||
}
|
||||
controller.abort()
|
||||
expect(deltas).toEqual(["Hello"])
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("the default client is created lazily and can be disposed", async () => {
|
||||
expect(typeof AI.ai.llm.generate).toBe("function")
|
||||
expect(typeof AI.ai.image.generate).toBe("function")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Transcription } from "../../src/index.js"
|
||||
import { ElevenLabs } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js"
|
||||
|
||||
const model = ElevenLabs.configure({ apiKey: process.env.ELEVENLABS_API_KEY ?? "fixture" }).transcription("scribe_v2")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "elevenlabs-transcription",
|
||||
provider: "elevenlabs",
|
||||
protocol: "elevenlabs-transcription",
|
||||
requires: ["ELEVENLABS_API_KEY"],
|
||||
options: audioRecording,
|
||||
})
|
||||
|
||||
describe("ElevenLabs Transcription recorded", () => {
|
||||
recorded.effect("transcribes audio with word timestamps", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = Transcription.request({ model, audio: yield* audio, timestamps: "word" })
|
||||
const response = yield* Transcription.generate(request)
|
||||
|
||||
expect(response.text).toMatch(TRANSCRIPT)
|
||||
expect(response.words?.map((word) => word.text)).toEqual(["Hello", "from", "OpenCode"])
|
||||
expect(response.words?.every((word) => word.speaker === undefined && (word.confidence ?? 0) > 0)).toBe(true)
|
||||
expect(response.segments).toBeUndefined()
|
||||
expect(response.language).toBe("eng")
|
||||
expect(response.durationSeconds).toBeGreaterThan(0)
|
||||
expect(response.usage).toEqual({ type: "seconds", seconds: response.durationSeconds })
|
||||
expect(response.providerMetadata?.elevenlabs?.transcriptionId).toEqual(expect.any(String))
|
||||
|
||||
const events = Array.from(yield* Stream.runCollect(Transcription.stream(request)))
|
||||
expect(events.map((event) => event.type)).toEqual(["finish"])
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("groups diarized words into speaker turns", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Transcription.generate({ model, audio: yield* dialog, diarize: true })
|
||||
|
||||
expect(response.segments?.map((segment) => segment.speaker)).toEqual(["speaker_0", "speaker_1"])
|
||||
expect(response.segments?.[0].text).toMatch(/^Did the release ship\?$/)
|
||||
expect(response.segments?.[1].text).toMatch(/^Yes, it shipped this morning\.?$/)
|
||||
expect(response.segments?.map((segment) => segment.text).join(" ")).toBe(response.text)
|
||||
expect(response.words?.some((word) => word.text.trim() === "")).toBe(false)
|
||||
expect(new Set(response.words?.map((word) => word.speaker))).toEqual(new Set(["speaker_0", "speaker_1"]))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolRuntime, toDefinitions } from "../../src/index.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { weatherRuntimeTool, weatherToolName } from "../recorded-scenarios.js"
|
||||
|
||||
const model = OpenAICompatible.configure({
|
||||
provider: "google",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.8-flash")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-compatible-chat",
|
||||
provider: "google",
|
||||
protocol: "openai-chat",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
tags: ["tool", "tool-loop", "continuation"],
|
||||
metadata: { model: model.id },
|
||||
})
|
||||
|
||||
describe("Gemini OpenAI-compatible Chat recorded", () => {
|
||||
recorded.effect.with(
|
||||
"replays thought signatures through a parallel tool loop",
|
||||
{ cassette: "openai-compatible-chat/gemini-parallel-tool-signatures" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tools = { [weatherToolName]: weatherRuntimeTool }
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Call get_weather for every requested city in parallel, then answer in one short sentence.",
|
||||
prompt: "What is the weather in Paris and in Tokyo?",
|
||||
tools: toDefinitions(tools),
|
||||
cache: "none",
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
const calls = first.events.filter(LLMEvent.is.toolCall)
|
||||
expect(calls.map((call) => call.input)).toEqual([{ city: "Paris" }, { city: "Tokyo" }])
|
||||
const extraContent = calls[0]?.providerMetadata?.google?.extraContent
|
||||
expect(extraContent).toEqual({ google: { thought_signature: expect.any(String) } })
|
||||
|
||||
const results = yield* Effect.forEach(calls, (call) => ToolRuntime.dispatch(tools, call))
|
||||
const continuation = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...calls.map((call, index) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: results[index]!.result }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const prepared = yield* compileRequest(continuation)
|
||||
const assistant = prepared.body.messages.find((message) => message.role === "assistant")
|
||||
expect(assistant?.role === "assistant" ? assistant.tool_calls?.[0]?.extra_content : undefined).toEqual(
|
||||
extraContent,
|
||||
)
|
||||
|
||||
const second = yield* LLMClient.generate(continuation)
|
||||
expect(second.events.filter(LLMEvent.is.toolCall)).toHaveLength(0)
|
||||
expect(second.text).toMatch(/Paris/)
|
||||
expect(second.text).toMatch(/Tokyo/)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -472,6 +472,45 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays Gemini thought signatures as tool call extra content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Weather in Paris and Tokyo?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
}),
|
||||
ToolCallPart.make({ id: "call_2", name: "lookup", input: { city: "Tokyo" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
Message.tool({ id: "call_2", name: "lookup", result: "Rainy" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const assistant = prepared.body.messages[1]
|
||||
expect(assistant?.role === "assistant" ? assistant.tool_calls : undefined).toEqual([
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: encodeJson({ city: "Paris" }) },
|
||||
extra_content: { google: { thought_signature: "sig_1" } },
|
||||
},
|
||||
{
|
||||
id: "call_2",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: encodeJson({ city: "Tokyo" }) },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = `call_${"a".repeat(48)}`
|
||||
@@ -1805,6 +1844,78 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves Gemini thought signatures on streamed parallel tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
// Gemini's OpenAI-compatible endpoint omits `index`, streams each call whole,
|
||||
// and signs only the first call of a parallel batch.
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
extra_content: { google: { thought_signature: "sig_1" } },
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: '{"city":"Paris"}' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "call_2", type: "function", function: { name: "lookup", arguments: '{"city":"Tokyo"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "stop"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_2",
|
||||
name: "lookup",
|
||||
input: { city: "Tokyo" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps extra content that arrives before the tool identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [
|
||||
{ index: 0, extra_content: { google: { thought_signature: "sig_1" } }, function: { arguments: "{" } },
|
||||
],
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "}" } }] }),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall).map((event) => event.providerMetadata)).toEqual([
|
||||
{ openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { AnthropicMessages, Gemini, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
|
||||
import { OpenAIChat } from "../src/protocols.js"
|
||||
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
|
||||
import { Tool, toDefinitions } from "../src/tool.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
@@ -9,126 +9,6 @@ import { compileRequest } from "../src/route/client.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
describe("tool schema projections", () => {
|
||||
test("only normalizes typed empty input structs, preserving raw schemas and output schemas", () => {
|
||||
const schema = { not: { type: "null" } }
|
||||
const definitions = Tool.toDefinitions({
|
||||
typed: Tool.make({
|
||||
description: "Typed",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({}),
|
||||
}),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: schema, execute: () => Effect.succeed({}) }),
|
||||
})
|
||||
expect(definitions[0]?.inputSchema).toEqual({ type: "object", properties: {}, additionalProperties: false })
|
||||
expect(definitions[0]?.outputSchema).toEqual(schema)
|
||||
expect(definitions[1]?.inputSchema).toEqual(schema)
|
||||
})
|
||||
|
||||
test("retains empty input descriptions and preserves named outputs and explicit raw schemas", () => {
|
||||
const parameters = Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" })
|
||||
const raw = { $ref: "#/$defs/Raw", $defs: { Raw: { not: { type: "null" }, description: "Raw schema" } } }
|
||||
const definitions = Tool.toDefinitions({
|
||||
typed: Tool.make({ description: "Ping", parameters, success: parameters, execute: () => Effect.succeed({}) }),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed({}) }),
|
||||
})
|
||||
expect(definitions[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
description: "No arguments",
|
||||
})
|
||||
expect(definitions[0]?.outputSchema).toEqual({
|
||||
$ref: "#/$defs/Ping",
|
||||
$defs: { Ping: { not: { type: "null" }, description: "No arguments" } },
|
||||
})
|
||||
expect(definitions[1]?.inputSchema).toEqual(raw)
|
||||
})
|
||||
|
||||
const empty = { type: "object", properties: {}, additionalProperties: false }
|
||||
const nonempty = {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
const raw = { type: "object", properties: { value: { type: "number" } }, additionalProperties: true }
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
route: OpenAIChat.route,
|
||||
tools: [empty, nonempty, raw].map((parameters, index) => ({
|
||||
type: "function",
|
||||
function: { name: ["ping", "lookup", "raw"][index], parameters },
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: OpenAIResponses.route,
|
||||
tools: [empty, nonempty, raw].map((parameters, index) => ({
|
||||
type: "function",
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
parameters,
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: AnthropicMessages.route,
|
||||
tools: [empty, nonempty, raw].map((input_schema, index) => ({
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
input_schema,
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: Gemini.route,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [empty, nonempty, raw].map((parametersJsonSchema, index) => ({
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
parametersJsonSchema,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
for (const input of [
|
||||
{ name: "plain", schema: Schema.Struct({}) },
|
||||
{ name: "described", schema: Schema.Struct({}).annotate({ description: "No arguments" }) },
|
||||
{ name: "named", schema: Schema.Struct({}).annotate({ identifier: "Ping" }) },
|
||||
{
|
||||
name: "named and described",
|
||||
schema: Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" }),
|
||||
},
|
||||
]) {
|
||||
it.effect(
|
||||
`${scenario.route.id} prepares ${input.name} empty Effect Struct tools alongside nonempty and raw schemas`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: scenario.route.with({ auth: Auth.bearer("test") }).model({ id: "test-model" }),
|
||||
prompt: "Use a tool.",
|
||||
tools: Tool.toDefinitions({
|
||||
ping: Tool.make({
|
||||
description: "Ping",
|
||||
parameters: input.schema,
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("pong"),
|
||||
}),
|
||||
lookup: Tool.make({
|
||||
description: "Lookup",
|
||||
parameters: Schema.Struct({ query: Schema.String }),
|
||||
success: Schema.String,
|
||||
execute: (input) => Effect.succeed(input.query),
|
||||
}),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed("raw") }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.tools).toMatchObject(scenario.tools)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
|
||||
expect(
|
||||
ToolSchemaProjection.moonshot({
|
||||
@@ -211,12 +91,7 @@ describe("tool schema projections", () => {
|
||||
const exclusive = { oneOf: [{ type: "object" }, { type: "object", required: ["a"] }] }
|
||||
const object = { type: "object", properties: {} }
|
||||
|
||||
expect(yield* parameters(parameterless)).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
description: "No input.",
|
||||
})
|
||||
expect(yield* parameters(parameterless)).toEqual({ type: "object", description: "No input." })
|
||||
expect(yield* parameters({})).toEqual({ type: "object" })
|
||||
expect(yield* parameters({ description: "Query", properties: { q: { type: "string" } } })).toEqual({
|
||||
type: "object",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/index.js"
|
||||
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
|
||||
import { AssemblyAI, Deepgram, ElevenLabs, Google, OpenAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
@@ -35,6 +35,9 @@ const formFields = (call: Call) =>
|
||||
const assemblyai = AssemblyAI.configure({ apiKey: "aai-key", baseURL: "https://assemblyai.test" }).transcription(
|
||||
"universal-3-5-pro",
|
||||
)
|
||||
const elevenlabs = ElevenLabs.configure({ apiKey: "test", baseURL: "https://elevenlabs.test" }).transcription(
|
||||
"scribe_v2",
|
||||
)
|
||||
|
||||
describe("Transcription", () => {
|
||||
it.effect("rejects what a route cannot honor before sending anything", () =>
|
||||
@@ -459,6 +462,115 @@ describe("Transcription", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects ElevenLabs prompts, webhooks, per-channel transcripts, and untimed diarization", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Transcription.generate({ model: elevenlabs, audio, prompt: "OpenCode" }),
|
||||
Transcription.generate({ model: elevenlabs, audio, providerOptions: { webhook: true } }),
|
||||
Transcription.generate({ model: elevenlabs, audio, http: { body: { use_multi_channel: true } } }),
|
||||
Transcription.generate({
|
||||
model: elevenlabs,
|
||||
audio,
|
||||
diarize: true,
|
||||
providerOptions: { timestamps_granularity: "none" },
|
||||
}),
|
||||
Transcription.generate({
|
||||
model: elevenlabs,
|
||||
audio: Media.ref("file_1", { provider: "elevenlabs", mediaType: "audio/mpeg" }),
|
||||
}),
|
||||
].map((effect) => Effect.flip(effect)),
|
||||
)
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.prompt"],
|
||||
["UnsupportedOperation", "transcription.webhook"],
|
||||
["UnsupportedOperation", "transcription.multichannel"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["InvalidRequest", false],
|
||||
],
|
||||
)
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("sends ElevenLabs URL audio as source_url and groups diarized words into speaker turns", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const token = (text: string, type: string, start: number, end: number, speaker_id?: string) => ({
|
||||
text,
|
||||
type,
|
||||
start,
|
||||
end,
|
||||
speaker_id,
|
||||
logprob: 0,
|
||||
})
|
||||
const response = yield* Transcription.generate({
|
||||
model: elevenlabs,
|
||||
audio: Media.url("https://a.test/call.mp3"),
|
||||
language: "en",
|
||||
speakers: 2,
|
||||
providerOptions: { keyterms: ["OpenCode", "Scribe"], tag_audio_events: true, diarize: false },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
observe(calls, input).pipe(
|
||||
Effect.as(
|
||||
json(input, {
|
||||
language_code: "ENG",
|
||||
text: "Ready? (laughs) Yes. Go",
|
||||
words: [
|
||||
token("Ready?", "word", 0, 0.5, "speaker_0"),
|
||||
token(" ", "spacing", 0.5, 0.6, "speaker_0"),
|
||||
token("(laughs)", "audio_event", 0.6, 1, "speaker_0"),
|
||||
token(" ", "spacing", 1, 1.1, "speaker_0"),
|
||||
token("Yes.", "word", 1.2, 1.5, "speaker_1"),
|
||||
token(" ", "spacing", 1.5, 1.6, "speaker_1"),
|
||||
token("Go", "word", 1.6, 1.9, "speaker_0"),
|
||||
],
|
||||
transcription_id: "tr_1",
|
||||
audio_duration_secs: 2,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// `observe` re-encodes the FormData with a new boundary, so read the boundary from the sent body.
|
||||
const boundary = /^--(\S+)/.exec(calls[0].body)?.[1]
|
||||
const form = yield* Effect.promise(() =>
|
||||
new Response(calls[0].body, {
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
}).formData(),
|
||||
)
|
||||
expect(calls[0].url).toBe("https://elevenlabs.test/v1/speech-to-text")
|
||||
expect(calls[0].headers.get("xi-api-key")).toBe("test")
|
||||
expect(Array.from(form.entries())).toEqual([
|
||||
["model_id", "scribe_v2"],
|
||||
["source_url", "https://a.test/call.mp3"],
|
||||
["language_code", "en"],
|
||||
["diarize", "true"],
|
||||
["num_speakers", "2"],
|
||||
["keyterms", "OpenCode"],
|
||||
["keyterms", "Scribe"],
|
||||
["tag_audio_events", "true"],
|
||||
])
|
||||
expect(response.segments).toEqual([
|
||||
{ text: "Ready?", startSeconds: 0, endSeconds: 0.5, speaker: "speaker_0" },
|
||||
{ text: "Yes.", startSeconds: 1.2, endSeconds: 1.5, speaker: "speaker_1" },
|
||||
{ text: "Go", startSeconds: 1.6, endSeconds: 1.9, speaker: "speaker_0" },
|
||||
])
|
||||
expect(response.words?.map((word) => [word.text, word.speaker, word.confidence])).toEqual([
|
||||
["Ready?", "speaker_0", 1],
|
||||
["Yes.", "speaker_1", 1],
|
||||
["Go", "speaker_0", 1],
|
||||
])
|
||||
expect(response.language).toBe("eng")
|
||||
expect(response.usage).toEqual({ type: "seconds", seconds: 2 })
|
||||
expect(response.providerMetadata).toEqual({ elevenlabs: { transcriptionId: "tr_1" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
|
||||
|
||||
+235
-26
@@ -1,9 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Media, Video, VideoClient, type GenerationEvent, type VideoEvent } from "../src/index.js"
|
||||
import { Fal, Google, Runway, XAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, json, observe, settle, type Call } from "./lib/http.js"
|
||||
import { dynamicResponse, json, observe, settle, type Call, type HandlerInput } from "./lib/http.js"
|
||||
|
||||
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
||||
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
@@ -162,27 +163,39 @@ describe("Video / Google Veo", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("surfaces an operation error as a failed generation with the provider body", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = {
|
||||
name: operation,
|
||||
done: true,
|
||||
error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" },
|
||||
}
|
||||
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure)),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("Google Veo operation failed: Prompt violates policy")
|
||||
expect(error.reason.body).toBe(JSON.stringify(failure))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
for (const terminal of [
|
||||
{ error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" }, tag: "InvalidRequest" },
|
||||
{ error: { code: 9, message: "Unsupported resolution", status: "FAILED_PRECONDITION" }, tag: "InvalidRequest" },
|
||||
{ error: { code: 11, message: "Duration out of range", status: "OUT_OF_RANGE" }, tag: "InvalidRequest" },
|
||||
{ error: { code: 7, message: "Permission denied", status: "PERMISSION_DENIED" }, tag: "Authentication" },
|
||||
{ error: { code: 16, message: "Invalid credentials", status: "UNAUTHENTICATED" }, tag: "Authentication" },
|
||||
{ error: { code: 8, message: "Quota exceeded", status: "RESOURCE_EXHAUSTED" }, tag: "RateLimit" },
|
||||
{ error: { code: 13, message: "Internal error", status: "INTERNAL" }, tag: "ProviderInternal" },
|
||||
{ error: { code: 14, message: "Service unavailable", status: "UNAVAILABLE" }, tag: "ProviderInternal" },
|
||||
{ error: { message: "Something broke" }, tag: "ProviderInternal" },
|
||||
]) {
|
||||
it.effect(
|
||||
`surfaces ${terminal.error.status ?? "an uncoded"} operation error as ${terminal.tag} with the provider body`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const failure = { name: operation, done: true, error: terminal.error }
|
||||
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe(terminal.tag)
|
||||
expect(error.message).toBe(`Google Veo operation failed: ${terminal.error.message}`)
|
||||
expect(error.reason.body).toBe(JSON.stringify(failure))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("reports fully filtered output as a content policy failure", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -332,12 +345,37 @@ describe("Video / xAI", () => {
|
||||
for (const terminal of [
|
||||
{
|
||||
body: { status: "failed", error: { code: "invalid_argument", message: "Prompt cannot be empty." } },
|
||||
tag: "ProviderInternal",
|
||||
tag: "InvalidRequest",
|
||||
message: "xAI Video generation failed (invalid_argument): Prompt cannot be empty.",
|
||||
},
|
||||
{
|
||||
body: { status: "failed", error: { code: "failed_precondition", message: "Extension is not supported." } },
|
||||
tag: "InvalidRequest",
|
||||
message: "xAI Video generation failed (failed_precondition): Extension is not supported.",
|
||||
},
|
||||
{
|
||||
body: { status: "failed", error: { code: "permission_denied", message: "Team lacks access." } },
|
||||
tag: "Authentication",
|
||||
message: "xAI Video generation failed (permission_denied): Team lacks access.",
|
||||
},
|
||||
{
|
||||
body: { status: "failed", error: { code: "service_unavailable", message: "Overloaded." } },
|
||||
tag: "ProviderInternal",
|
||||
message: "xAI Video generation failed (service_unavailable): Overloaded.",
|
||||
},
|
||||
{
|
||||
body: { status: "failed", error: { code: "internal_error", message: "Generation failed." } },
|
||||
tag: "ProviderInternal",
|
||||
message: "xAI Video generation failed (internal_error): Generation failed.",
|
||||
},
|
||||
{
|
||||
body: { status: "failed", error: { code: "constructor", message: "Future code." } },
|
||||
tag: "ProviderInternal",
|
||||
message: "xAI Video generation failed (constructor): Future code.",
|
||||
},
|
||||
{ body: { status: "expired" }, tag: "InvalidRequest", message: "xAI Video request req_1 expired" },
|
||||
]) {
|
||||
it.effect(`surfaces ${terminal.body.status} generations with the provider body`, () =>
|
||||
it.effect(`surfaces ${terminal.body.error?.code ?? terminal.body.status} generations with the provider body`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(terminal.tag)
|
||||
@@ -558,7 +596,10 @@ describe("Video / fal", () => {
|
||||
]) {
|
||||
it.effect(`fails await for ${failure.name} with the response_url body and HTTP context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
|
||||
// A transient 500 on the result fetch is retried first; the body and HTTP context survive the final failure.
|
||||
const fiber = yield* Effect.forkChild(Video.generate({ model, prompt: "x" }).pipe(Effect.flip))
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
expect(error.reason._tag).toBe(failure.tag)
|
||||
expect(error.reason.body).toBe(JSON.stringify(failure.result.body))
|
||||
expect(error.reason.http).toMatchObject({ url: urls.response, status: failure.result.status })
|
||||
@@ -765,6 +806,11 @@ describe("Video / Runway", () => {
|
||||
tag: "ProviderInternal",
|
||||
message: "Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke",
|
||||
},
|
||||
{
|
||||
body: { status: "FAILED", failure: "Unsupported dimensions", failureCode: "ASSET.INVALID" },
|
||||
tag: "InvalidRequest",
|
||||
message: "Runway task failed (ASSET.INVALID): Unsupported dimensions",
|
||||
},
|
||||
{ body: { status: "CANCELLED" }, tag: "InvalidRequest", message: "Runway task task_1 was cancelled" },
|
||||
]) {
|
||||
it.effect(`surfaces ${terminal.body.failureCode ?? terminal.body.status} with the task body`, () =>
|
||||
@@ -908,6 +954,169 @@ describe("Video / Runway", () => {
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transient read failures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / transient read failures", () => {
|
||||
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
|
||||
const succeeded = { id: "task_1", status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] }
|
||||
const failure = (input: HandlerInput, status: number, headers?: Record<string, string>) =>
|
||||
json(input, { error: `HTTP ${status}` }, { status, headers })
|
||||
const methods = (calls: ReadonlyArray<Call>) => calls.map((call) => call.method)
|
||||
|
||||
it.effect("retries a 503 status poll and a 503 result read, then returns the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* settle(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }),
|
||||
5,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
// 1: status fails, 2: status succeeds, 3: result fails, 4: result succeeds.
|
||||
if (nth === 1 || nth === 3) return failure(input, 503)
|
||||
return json(input, succeeded)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" })
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for a 429 retry-after before polling again", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const fiber = yield* Effect.forkChild(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
if (nth === 1) return failure(input, 429, { "retry-after": "10" })
|
||||
return json(input, succeeded)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* TestClock.adjust("9 seconds")
|
||||
expect(methods(calls)).toEqual(["POST", "GET"])
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(fiber)
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a 400 status poll without retrying", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 400)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(methods(calls)).toEqual(["POST", "GET"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops retrying at poll.timeout with a Timeout reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* settle(
|
||||
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
|
||||
Effect.flip,
|
||||
),
|
||||
6,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 503)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds a streamed result read's retries by poll.timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* settle(
|
||||
Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flip,
|
||||
),
|
||||
6,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
return nth === 1 ? json(input, succeeded) : failure(input, 503)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never retries a failed submit", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(layer((input) => observe(calls, input).pipe(Effect.map(() => failure(input, 503))))),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(methods(calls)).toEqual(["POST"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never retries a failed cancel", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model, prompt: "x" })
|
||||
return yield* generation.cancel().pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
if (call.method === "DELETE") return failure(input, 503)
|
||||
return json(input, { id: "task_1", status: "RUNNING" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(methods(calls)).toEqual(["POST", "GET", "DELETE"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared queued behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -45,7 +45,7 @@ const Current = Persistence.struct({ session: SessionsSchema })
|
||||
|
||||
export const ModelSelectionSchema = Persistence.migrate(
|
||||
Current,
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).pipe(
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("persistence schemas", () => {
|
||||
|
||||
test("legacy migration observes missing fields before initial defaults are applied", () => {
|
||||
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
|
||||
const stored = Persistence.legacy({
|
||||
const stored = Schema.Struct({
|
||||
mode: Schema.optional(Schema.Unknown),
|
||||
expanded: Schema.optional(Schema.Boolean),
|
||||
}).pipe(
|
||||
@@ -53,38 +53,6 @@ describe("persistence schemas", () => {
|
||||
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
|
||||
})
|
||||
|
||||
test("legacy read shapes carry unknown nested fields through to the current schema", () => {
|
||||
const current = Persistence.struct({
|
||||
general: Persistence.struct({
|
||||
mode: Schema.Literals(["compact", "full"]),
|
||||
theme: Schema.String,
|
||||
nested: Persistence.struct({ keep: Schema.Boolean }),
|
||||
}),
|
||||
other: Persistence.struct({ flag: Schema.Boolean }),
|
||||
})
|
||||
const stored = Persistence.legacy({
|
||||
general: Persistence.optional(Persistence.legacy({ expanded: Persistence.optional(Schema.Boolean) })),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const general = value.general
|
||||
if (!general || general.expanded === undefined) return value
|
||||
return { ...value, general: { ...general, mode: general.expanded ? "full" : "compact" } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
const schema = Persistence.withInitial(Persistence.migrate(current, stored), {
|
||||
general: { mode: "compact", theme: "light", nested: { keep: false } },
|
||||
other: { flag: false },
|
||||
})
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ general: { expanded: true, theme: "dark", nested: { keep: true } }, other: { flag: true } })).toEqual({
|
||||
general: { mode: "full", theme: "dark", nested: { keep: true } },
|
||||
other: { flag: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
|
||||
const current = Persistence.struct({
|
||||
amount: Schema.NumberFromString.check(Schema.isFinite()),
|
||||
|
||||
@@ -23,11 +23,13 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition) ? SchemaParser.decodeUnknownResult(definition.read) : Result.succeed<unknown>
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformEffect((value) =>
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
|
||||
),
|
||||
encode: SchemaGetter.transform((value) => encode(value)),
|
||||
@@ -36,12 +38,6 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
)
|
||||
}
|
||||
|
||||
// A legacy read shape declares only the fields it migrates. Decoding strips unknown keys, so every
|
||||
// struct level in a `migrate` read schema must stay open for the current fields it does not name.
|
||||
export function legacy<const Fields extends Schema.Struct.Fields>(fields: Fields) {
|
||||
return Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.Unknown)])
|
||||
}
|
||||
|
||||
// Object-level codecs own their recovery. Plain structs can recover fields independently.
|
||||
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
|
||||
@@ -53,7 +53,7 @@ const State = Persistence.struct({
|
||||
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
|
||||
return Persistence.migrate(
|
||||
State,
|
||||
Persistence.legacy({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
|
||||
@@ -185,9 +185,9 @@ function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" |
|
||||
|
||||
export const settingsPersistence = Persistence.migrate(
|
||||
settingsSchema,
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
general: Persistence.optional(
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
|
||||
timelineDetail: Schema.optional(
|
||||
Schema.NullOr(
|
||||
|
||||
@@ -176,16 +176,16 @@ export const layoutSchema = Persistence.struct({
|
||||
|
||||
export const layoutPersistence = Persistence.migrate(
|
||||
layoutSchema,
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
sidebar: Persistence.optional(
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
|
||||
workspacesDefault: Persistence.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
review: Persistence.optional(Persistence.legacy({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
fileTree: Persistence.optional(
|
||||
Persistence.legacy({
|
||||
Schema.Struct({
|
||||
opened: Persistence.optional(Schema.Boolean),
|
||||
width: Persistence.optional(Schema.Finite),
|
||||
tab: Persistence.optional(Schema.Literals(["changes", "all"])),
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { Updater } from "../services/updater"
|
||||
|
||||
export const PrintLogs = GlobalFlag.Setting("print-logs")({
|
||||
flag: Flag.Boolean("print-logs").pipe(
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -13,23 +13,23 @@ export const PrintLogs = GlobalFlag.Setting("print-logs")({
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const ServerParams = {
|
||||
standalone: Flag.Boolean("standalone").pipe(
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
server: Flag.String("server").pipe(
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
}
|
||||
|
||||
const PermissionParams = {
|
||||
auto: Flag.Boolean("auto").pipe(
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.Boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.Boolean("dangerously-skip-permissions").pipe(
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
@@ -40,32 +40,32 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
...ServerParams,
|
||||
...PermissionParams,
|
||||
directory: Argument.String("directory").pipe(
|
||||
directory: Argument.string("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.String("session").pipe(
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.Literals("method", Updater.methods).pipe(
|
||||
method: Flag.choice("method", Updater.methods).pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Installation method to use"),
|
||||
Flag.optional,
|
||||
@@ -75,21 +75,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("uninstall", {
|
||||
description: "Uninstall OpenCode and remove all related files",
|
||||
params: {
|
||||
keepConfig: Flag.Boolean("keep-config").pipe(
|
||||
keepConfig: Flag.boolean("keep-config").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Keep configuration files"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
keepData: Flag.Boolean("keep-data").pipe(
|
||||
keepData: Flag.boolean("keep-data").pipe(
|
||||
Flag.withAlias("d"),
|
||||
Flag.withDescription("Keep session data and snapshots"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
dryRun: Flag.Boolean("dry-run").pipe(
|
||||
dryRun: Flag.boolean("dry-run").pipe(
|
||||
Flag.withDescription("Show what would be removed without removing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
force: Flag.Boolean("force").pipe(
|
||||
force: Flag.boolean("force").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("Skip confirmation prompts"),
|
||||
Flag.withDefault(false),
|
||||
@@ -101,17 +101,17 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
...ServerParams,
|
||||
request: Argument.String("operation | method path").pipe(
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
),
|
||||
data: Flag.String("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.String("header").pipe(
|
||||
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.string("header").pipe(
|
||||
Flag.withAlias("H"),
|
||||
Flag.withDescription("Request header in name:value form"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
param: Flag.KeyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
@@ -122,7 +122,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("paths", {
|
||||
description: "Show global paths (data, config, cache, state)",
|
||||
params: {
|
||||
name: Argument.Literals("name", [
|
||||
name: Argument.choice("name", [
|
||||
"db",
|
||||
"home",
|
||||
"data",
|
||||
@@ -134,9 +134,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
"log",
|
||||
"repos",
|
||||
]).pipe(
|
||||
Argument.withDescription(
|
||||
"Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos",
|
||||
),
|
||||
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
@@ -150,7 +148,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "list integrations and credentials",
|
||||
params: {
|
||||
...ServerParams,
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
@@ -160,12 +158,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "connect an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.String("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.String("answer").pipe(
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.string("answer").pipe(
|
||||
Flag.withDescription("Provider form answer (key=value; repeat for multiple fields)"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
@@ -175,11 +173,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "log out of a saved account",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.String("credential").pipe(
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -189,11 +187,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.String("credential").pipe(
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -208,21 +206,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.String("command").pipe(
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.String("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.KeyValuePair("header").pipe(
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.KeyValuePair("env").pipe(
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.Boolean("global").pipe(
|
||||
global: Flag.boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -231,12 +229,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: {
|
||||
name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional),
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -246,7 +244,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.Boolean("builtin").pipe(
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -255,13 +253,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.String("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("check", {
|
||||
description: "Check package plugins for updates",
|
||||
params: {
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -270,7 +268,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("update", {
|
||||
description: "Update package plugins",
|
||||
params: {
|
||||
target: Argument.String("target").pipe(
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -279,7 +277,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.String("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -292,108 +290,108 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Show shareable usage statistics",
|
||||
params: {
|
||||
...ServerParams,
|
||||
days: Flag.Int("days").pipe(
|
||||
days: Flag.integer("days").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
Flag.withDescription("Show the last N days; 0 means today"),
|
||||
Flag.optional,
|
||||
),
|
||||
year: Flag.Int("year").pipe(
|
||||
year: Flag.integer("year").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
|
||||
Flag.withDescription("Show a calendar year"),
|
||||
Flag.optional,
|
||||
),
|
||||
all: Flag.Boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.String("project").pipe(
|
||||
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.string("project").pipe(
|
||||
Flag.withDescription('Filter by project ID, or use "." for the current project'),
|
||||
Flag.optional,
|
||||
),
|
||||
models: Flag.Boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.Boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.Boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.Boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.Int("limit").pipe(
|
||||
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.integer("limit").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Number of rows in detailed sections"),
|
||||
Flag.withDefault(5),
|
||||
),
|
||||
json: Flag.Boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.String("session").pipe(
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.Boolean("replay").pipe(
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
||||
Flag.optional,
|
||||
),
|
||||
replayLimit: Flag.Int("replay-limit").pipe(
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
|
||||
Flag.optional,
|
||||
),
|
||||
model: Flag.String("model").pipe(
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.Boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("run", {
|
||||
description: "Run OpenCode with a message",
|
||||
params: {
|
||||
...ServerParams,
|
||||
message: Argument.String("message").pipe(
|
||||
message: Argument.string("message").pipe(
|
||||
Argument.withDescription("Message to send"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.String("session").pipe(
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session before continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
model: Flag.String("model").pipe(
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model#variant"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
file: Flag.String("file").pipe(
|
||||
file: Flag.string("file").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("File to attach to the message"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
title: Flag.String("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.Boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
...PermissionParams,
|
||||
},
|
||||
}),
|
||||
@@ -404,13 +402,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "List top-level sessions in the current project, newest first",
|
||||
params: {
|
||||
...ServerParams,
|
||||
maxCount: Flag.Int("max-count").pipe(
|
||||
maxCount: Flag.integer("max-count").pipe(
|
||||
Flag.withAlias("n"),
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
|
||||
Flag.optional,
|
||||
),
|
||||
format: Flag.Literals("format", ["table", "json"]).pipe(
|
||||
format: Flag.choice("format", ["table", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("table"),
|
||||
),
|
||||
@@ -420,18 +418,18 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Delete a session and its child sessions",
|
||||
params: {
|
||||
...ServerParams,
|
||||
sessionID: Argument.String("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Argument.String("session").pipe(
|
||||
session: Argument.string("session").pipe(
|
||||
Argument.withDescription("Session ID to export"),
|
||||
Argument.optional,
|
||||
),
|
||||
sanitize: Flag.Boolean("sanitize").pipe(
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -441,8 +439,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.String("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.String("directory").pipe(
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
@@ -460,8 +458,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: {
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.String("name").pipe(
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -470,11 +468,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: {
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.String("value").pipe(
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.string("value").pipe(
|
||||
Argument.withDescription("Setting value or environment variable name"),
|
||||
),
|
||||
nestedValue: Argument.String("env-value").pipe(
|
||||
nestedValue: Argument.string("env-value").pipe(
|
||||
Argument.withDescription("Environment variable value"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -483,8 +481,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: {
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.String("name").pipe(
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -501,7 +499,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("pair", {
|
||||
description: "Print one-time links to connect a browser or app",
|
||||
params: {
|
||||
url: Flag.String("url").pipe(
|
||||
url: Flag.string("url").pipe(
|
||||
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
@@ -519,15 +517,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.String("hostname").pipe(Flag.optional),
|
||||
port: Flag.Int("port").pipe(Flag.optional),
|
||||
cors: Flag.String("cors").pipe(
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.Boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.Boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Config } from "effect"
|
||||
// The opencode server password: sent by clients connecting to an explicit
|
||||
// --server, and adopted by a manually run or standalone server. The legacy
|
||||
// name is still honored.
|
||||
export const password = Config.Redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.Redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
Config.withDefault(undefined),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
@@ -12,7 +11,7 @@ const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
export const register = Effect.fnUntraced(function* (options: {
|
||||
readonly address: NetAddress.SocketAddress
|
||||
readonly address: HttpServer.Address
|
||||
readonly password: string
|
||||
readonly id: string
|
||||
readonly file: string
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Effect, Schema, Stdio, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
|
||||
const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
|
||||
const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
|
||||
@@ -11,15 +10,16 @@ export const askpass = Effect.gen(function* () {
|
||||
const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
|
||||
const stdio = yield* Stdio.Stdio
|
||||
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
|
||||
const writer = yield* socket.writer
|
||||
const write = yield* socket.writer
|
||||
const response = { text: "" }
|
||||
yield* Effect.all(
|
||||
[
|
||||
Effect.gen(function* () {
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
while (true) response.text += (yield* pull).join("")
|
||||
}).pipe(Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void)),
|
||||
writer.write(
|
||||
socket.runString((text) =>
|
||||
Effect.sync(() => {
|
||||
response.text += text
|
||||
}),
|
||||
),
|
||||
write(
|
||||
JSON.stringify({
|
||||
token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
|
||||
text: process.argv.slice(2).join(" "),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -502,7 +501,7 @@ test("service registration replaces a stale owner with the bound address", async
|
||||
try {
|
||||
const cleanup = await Effect.runPromise(
|
||||
ServiceRegistration.register({
|
||||
address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 4321),
|
||||
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
|
||||
password: "secret",
|
||||
id: "owner",
|
||||
file: registration,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.117",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JsonPointer, Schema, SchemaAST } from "effect"
|
||||
import { JsonPointer, Schema } from "effect"
|
||||
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
|
||||
|
||||
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||
@@ -12,14 +12,8 @@ const renderKey = (name: string): string => (identifierSegment.test(name) ? name
|
||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
schema.type === "string" &&
|
||||
Array.isArray(schema.enum) &&
|
||||
(schema.enum.length === 1 ||
|
||||
(schema.enum.length === 3 && ["NaN", "Infinity", "-Infinity"].every((value) => schema.enum?.includes(value)))) &&
|
||||
schema.enum.every((value) => value === "NaN" || value === "Infinity" || value === "-Infinity")
|
||||
|
||||
const definitionName = (ref: string): string | undefined => {
|
||||
const tokens = JsonPointer.parseUriFragment(ref)
|
||||
return tokens?.length === 2 && (tokens[0] === "$defs" || tokens[0] === "definitions") ? tokens[1] : undefined
|
||||
}
|
||||
schema.enum.length === 1 &&
|
||||
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
|
||||
|
||||
const intersection = (members: ReadonlyArray<string>): string => {
|
||||
const concrete = members.filter((member) => member !== "unknown")
|
||||
@@ -44,7 +38,8 @@ const hasUnresolvedRef = (
|
||||
if (visited.has(schema)) return false
|
||||
const nextVisited = new Set([...visited, schema])
|
||||
if (schema.$ref !== undefined) {
|
||||
const name = definitionName(schema.$ref)
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
|
||||
if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
|
||||
}
|
||||
@@ -135,7 +130,8 @@ const renderSchema = (
|
||||
? ctx
|
||||
: { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
|
||||
if (schema.$ref) {
|
||||
const name = definitionName(schema.$ref)
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
|
||||
return intersection([
|
||||
renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
|
||||
@@ -207,7 +203,7 @@ const renderSchema = (
|
||||
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
|
||||
try {
|
||||
const visible = decoded ? Schema.toType(schema) : schema
|
||||
const document = Schema.toJsonSchemaDocument(visible, { onExcessProperty: "error" }) as {
|
||||
const document = Schema.toJsonSchemaDocument(visible) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
@@ -234,7 +230,7 @@ export type InputProperty = {
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input, { onExcessProperty: "error" }) as {
|
||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
@@ -245,7 +241,8 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
const definitions = document.definitions ?? {}
|
||||
let schema = document.schema
|
||||
if (schema.$ref !== undefined) {
|
||||
const name = definitionName(schema.$ref)
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
const resolved = name === undefined ? undefined : definitions[name]
|
||||
if (resolved === undefined) return []
|
||||
schema = resolved
|
||||
@@ -264,13 +261,10 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
// Effect 4.0 models an empty Struct as a non-nullish object, so inspect the encoded AST callers supply.
|
||||
// Raw JSON Schema inputs retain the compact `{}` rendering check.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => {
|
||||
if (!isEffectSchema(tool.input)) return inputTypeScript(tool) === "{}"
|
||||
const encoded = SchemaAST.toEncoded(tool.input.ast)
|
||||
return SchemaAST.isObjects(encoded) && encoded.propertySignatures.length === 0 && encoded.indexSignatures.length === 0
|
||||
}
|
||||
// Empty object schemas render as `{}` in compact form; anything with properties,
|
||||
// an index signature, or union members renders differently, so equality is a
|
||||
// conservative emptiness test for both Effect and JSON Schema inputs.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => inputTypeScript(tool) === "{}"
|
||||
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import {
|
||||
decodeInput,
|
||||
inputProperties,
|
||||
inputTypeScript,
|
||||
jsonSchemaToTypeScript,
|
||||
outputTypeScript,
|
||||
} from "../src/tool-schema.js"
|
||||
import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
|
||||
|
||||
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
|
||||
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
|
||||
@@ -531,34 +525,6 @@ describe("pretty signature rendering", () => {
|
||||
})
|
||||
|
||||
describe("JSON Schema definition scope", () => {
|
||||
test.each(["Lookup Input", "café", "A/B~C% D#E"])(
|
||||
"resolves Effect definition %s in signatures and input metadata",
|
||||
(identifier) => {
|
||||
const tool = Tool.make({
|
||||
description: "Named tool",
|
||||
input: Schema.Struct({ city: Schema.String }).annotate({ identifier }),
|
||||
output: Schema.Struct({ city: Schema.String }).annotate({ identifier }),
|
||||
execute: (input) => Effect.succeed(input),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("{ city: string }")
|
||||
expect(outputTypeScript(tool)).toBe("{ city: string }")
|
||||
expect(inputProperties(tool)).toEqual([{ name: "city", description: undefined, required: true }])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["#/$defs/%", "#/$defs/A~2B", "#/$defs/Missing", "#/$defs/Loop", "https://example.test/schema"])(
|
||||
"keeps malformed, unresolved, and recursive reference %s unknown",
|
||||
($ref) => {
|
||||
const tool = Tool.make({
|
||||
description: "Unresolved tool",
|
||||
input: { $ref, $defs: { Loop: { $ref: "#/$defs/Loop" } } },
|
||||
execute: () => Effect.succeed(null),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("unknown")
|
||||
expect(inputProperties(tool)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
|
||||
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("string")
|
||||
@@ -646,51 +612,14 @@ describe("non-identifier property names render as quoted keys", () => {
|
||||
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
|
||||
execute: () => Effect.succeed(null),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
|
||||
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
|
||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number | "Infinity" | "-Infinity" | "NaN" }')
|
||||
expect(inputTypeScript(tool, true)).toBe(
|
||||
["{", ' "foo-bar": string,', ' plain?: number | "Infinity" | "-Infinity" | "NaN",', "}"].join("\n"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("union schemas render every alternative", () => {
|
||||
test("Effect numbers advertise the numeric values accepted by the input decoder", () => {
|
||||
const tool = Tool.make({
|
||||
description: "Numeric tool",
|
||||
input: Schema.Struct({ amount: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
execute: (input) => Effect.succeed(input.amount),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("{ amount: number }")
|
||||
expect(inputTypeScript(tool, true)).toBe("{\n amount: number,\n}")
|
||||
expect(outputTypeScript(tool)).toBe("number")
|
||||
expect(outputTypeScript(tool, true)).toBe("number")
|
||||
expect(decodeInput(tool, { amount: 42 })).toEqual({ amount: 42 })
|
||||
for (const amount of ["NaN", "Infinity", "-Infinity"]) {
|
||||
expect(() => decodeInput(tool, { amount })).toThrow("Expected number")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps unrelated and partial grouped string enums alongside numbers", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
anyOf: [{ type: "number" }, { type: "string", enum: ["NaN", "Infinity", "unknown"] }],
|
||||
}),
|
||||
).toBe('number | "NaN" | "Infinity" | "unknown"')
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
anyOf: [{ type: "number" }, { type: "string", enum: ["NaN", "Infinity"] }],
|
||||
}),
|
||||
).toBe('number | "NaN" | "Infinity"')
|
||||
const tool = Tool.make({
|
||||
description: "Number or status",
|
||||
input: Schema.Union([Schema.Number, Schema.Literal("unknown")]),
|
||||
output: Schema.Union([Schema.Number, Schema.Literal("unknown")]),
|
||||
execute: (input) => Effect.succeed(input),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toContain('"unknown"')
|
||||
expect(outputTypeScript(tool)).toContain('"unknown"')
|
||||
expect(decodeInput(tool, "unknown")).toBe("unknown")
|
||||
})
|
||||
|
||||
test("anyOf with a number branch keeps sibling alternatives", () => {
|
||||
const schema = {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
@@ -878,73 +807,6 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty input signatures agree with decoding", () => {
|
||||
test.each([
|
||||
{ name: "a plain empty struct", input: Schema.Struct({}) },
|
||||
{
|
||||
name: "an annotated empty struct",
|
||||
input: Schema.Struct({}).annotate({ identifier: "Empty", description: "No input" }),
|
||||
},
|
||||
{ name: "a checked empty struct", input: Schema.Struct({}).check(Schema.makeFilter(() => true)) },
|
||||
{
|
||||
name: "an empty encoded struct transformed to a non-empty struct",
|
||||
input: Schema.Struct({}).pipe(
|
||||
Schema.decodeTo(Schema.Struct({ token: Schema.String }), {
|
||||
decode: SchemaGetter.transform(() => ({ token: "x" })),
|
||||
encode: SchemaGetter.transform(() => ({})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ name: "a raw JSON Schema empty object", input: { type: "object", properties: {} } },
|
||||
])("$name advertises () and runs with zero arguments", async ({ input }) => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
ping: Tool.make({ description: "Ping", input, output: Schema.String, execute: () => Effect.succeed("pong") }),
|
||||
},
|
||||
})
|
||||
const signature = "tools.ping(): Promise<string>"
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.ping" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
expect(await Effect.runPromise(runtime.execute("return await tools.ping()"))).toMatchObject({
|
||||
ok: true,
|
||||
value: "pong",
|
||||
})
|
||||
})
|
||||
|
||||
test("a required encoded input transformed to an empty struct keeps its required fields", async () => {
|
||||
const consume = Tool.make({
|
||||
description: "Consume token",
|
||||
input: Schema.Struct({ token: Schema.String }).pipe(
|
||||
Schema.decodeTo(Schema.Struct({}), {
|
||||
decode: SchemaGetter.transform(() => ({})),
|
||||
encode: SchemaGetter.transform(() => ({ token: "x" })),
|
||||
}),
|
||||
),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("consumed"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { consume } })
|
||||
const signature = "tools.consume({\n token: string,\n}): Promise<string>"
|
||||
expect(() => decodeInput(consume, {})).toThrow()
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.consume" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
expect(await Effect.runPromise(runtime.execute("return await tools.consume()"))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "InvalidToolInput" },
|
||||
})
|
||||
expect(await Effect.runPromise(runtime.execute('return await tools.consume({ token: "t" })'))).toMatchObject({
|
||||
ok: true,
|
||||
value: "consumed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-identifier tool paths", () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a Context7 library ID",
|
||||
|
||||
@@ -66,12 +66,6 @@
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
"node": "./src/util/process-lock-ffi.node.ts",
|
||||
"default": "./src/util/process-lock-ffi.bun.ts"
|
||||
},
|
||||
"#v1-migration": {
|
||||
"types": "./src/database/v1-migration.bun.ts",
|
||||
"bun": "./src/database/v1-migration.bun.ts",
|
||||
@@ -98,8 +92,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:"
|
||||
"drizzle-kit": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
|
||||
@@ -26,7 +26,6 @@ const result = await Bun.build({
|
||||
"#fff",
|
||||
"#photon-wasm",
|
||||
"#shell-parser-wasm",
|
||||
"#process-lock-ffi",
|
||||
"#v1-migration",
|
||||
],
|
||||
splitting: true,
|
||||
|
||||
@@ -99,7 +99,7 @@ export const layer = (options?: Options) =>
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const updateLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
@@ -330,8 +330,7 @@ export const layer = (options?: Options) =>
|
||||
function* (patch: Patch) {
|
||||
const directory = initial.global ?? AbsolutePath.make(globalService.config)
|
||||
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
|
||||
const filepath =
|
||||
(yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const filepath = (yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
|
||||
const updated = yield* Effect.try({
|
||||
try: () =>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type Result =
|
||||
}
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
|
||||
@@ -196,11 +196,11 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
const agent = legacy
|
||||
? Option.getOrUndefined(
|
||||
Option.map(
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all" }),
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
),
|
||||
)
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all" }))
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
|
||||
@@ -48,9 +48,10 @@ const proxy = (value: string, environment: Environment = process.env) => {
|
||||
)
|
||||
}
|
||||
|
||||
const constructorOptions = (input: Socket.WebSocketConstructorOptions | undefined): WebSocketOptions => {
|
||||
const constructorOptions = (input: string | Array<string> | undefined): WebSocketOptions => {
|
||||
if (typeof input === "string" || Array.isArray(input)) return { protocols: input }
|
||||
return { headers: input?.headers === undefined ? undefined : Headers.fromInput(input.headers) }
|
||||
// AI routes pass handshake options through Effect's browser-shaped constructor.
|
||||
return (input ?? {}) as WebSocketOptions
|
||||
}
|
||||
|
||||
const proxyAgent = (url: string, selectedProxy: string | undefined) => {
|
||||
|
||||
@@ -30,12 +30,6 @@ export interface ExternalDirectoryAuthorization {
|
||||
readonly save: string
|
||||
}
|
||||
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: AbsolutePath
|
||||
/** Location-relative for internal paths, absolute for external paths. */
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export * as File from "./file.js"
|
||||
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
|
||||
export const Diff = FileDiff.Info
|
||||
export type Diff = typeof Diff.Type
|
||||
@@ -7,7 +7,7 @@ import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
import { gitExecutable } from "./util/git-executable.js"
|
||||
@@ -152,7 +152,7 @@ export interface Interface {
|
||||
to: TreeID
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], OperationError>
|
||||
readonly restore: (input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
@@ -571,7 +571,7 @@ const layer = Layer.effect(
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
} satisfies FileDiff.Info
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...(options.discovery === false ? vanillaReplacements : []),
|
||||
...(options.replacements ?? []),
|
||||
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
|
||||
Location.node.replace(Location.boundNode(ref)),
|
||||
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/** @deprecated Use FileAccess for path resolution and authorization. */
|
||||
export { FileAccess as LocationMutation } from "./file-access.js"
|
||||
export * from "./file-access.js"
|
||||
@@ -8,7 +8,6 @@ import { LocationServiceMap } from "./location-service-map.js"
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
export type LocationServices = Instance.Services
|
||||
export type LocationError = Instance.Error
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
|
||||
@@ -16,12 +16,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
|
||||
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||
|
||||
const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const resolved = yield* project.resolve(ref.directory, options)
|
||||
const resolved = yield* project.resolve(ref.directory)
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
@@ -31,9 +31,9 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundNode = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref, options),
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
||||
@@ -45,8 +45,6 @@ export const ResourceTemplate = Mcp.ResourceTemplate
|
||||
export type ResourceTemplate = Mcp.ResourceTemplate
|
||||
export const ResourceCatalog = Mcp.ResourceCatalog
|
||||
export type ResourceCatalog = Mcp.ResourceCatalog
|
||||
export const ResourceContentPart = Mcp.ResourceContentPart
|
||||
export type ResourceContentPart = Mcp.ResourceContentPart
|
||||
export const ResourceContent = Mcp.ResourceContent
|
||||
export type ResourceContent = Mcp.ResourceContent
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface Interface {
|
||||
/** Records Project activity for recency ordering, at most once per minute per Project. */
|
||||
readonly activate: (projectID: ID) => Effect.Effect<void>
|
||||
/** Resolves and persists the owning Project. */
|
||||
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||
@@ -334,10 +334,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (
|
||||
input: AbsolutePath,
|
||||
_options?: { readonly discovery?: boolean },
|
||||
) {
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(input))
|
||||
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
|
||||
+20
-18
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Pty } from "@opencode/schema/pty"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PtyID } from "./pty/schema.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { lazy } from "./util/lazy.js"
|
||||
|
||||
@@ -35,6 +34,9 @@ type Active = {
|
||||
listeners: Disp[]
|
||||
}
|
||||
|
||||
export const ID = Pty.ID
|
||||
export type ID = Pty.ID
|
||||
|
||||
export const Info = Pty.Info
|
||||
export type Info = Types.DeepMutable<typeof Info.Type>
|
||||
|
||||
@@ -69,21 +71,21 @@ export type Attachment = {
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
ptyID: ID,
|
||||
}) {}
|
||||
|
||||
export class ExitedError extends Schema.TaggedError<ExitedError>()("Pty.ExitedError", {
|
||||
ptyID: PtyID,
|
||||
ptyID: ID,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
|
||||
readonly update: (id: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: ID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly attach: (id: ID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
|
||||
@@ -96,8 +98,8 @@ const layer = Layer.effect(
|
||||
const shell = yield* ShellSelect.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
const sessions = new Map<ID, Active>()
|
||||
const exitOrder: ID[] = []
|
||||
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
@@ -131,13 +133,13 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ ptyID: id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const removeSession = Effect.fnUntraced(function* (id: ID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
@@ -148,7 +150,7 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: ID) {
|
||||
yield* requireSession(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
@@ -157,12 +159,12 @@ const layer = Layer.effect(
|
||||
return Array.from(sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
const get = Effect.fn("Pty.get")(function* (id: ID) {
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const id = ID.ascending()
|
||||
const command = input.command || (yield* shell.resolve({ priority: "config" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
@@ -242,7 +244,7 @@ const layer = Layer.effect(
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const update = Effect.fn("Pty.update")(function* (id: ID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) session.info.title = input.title
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
@@ -250,12 +252,12 @@ const layer = Layer.effect(
|
||||
return session.info
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const write = Effect.fn("Pty.write")(function* (id: ID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: ID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ID as PtyID } from "@opencode/schema/pty"
|
||||
@@ -2,7 +2,7 @@ export * as PtyTicket from "./ticket.js"
|
||||
|
||||
import type { Workspace } from "@opencode/schema/workspace"
|
||||
import { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import { PtyID } from "./schema.js"
|
||||
import type { Pty } from "@opencode/schema/pty"
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
@@ -12,7 +12,7 @@ const CAPACITY = 10_000
|
||||
export const ConnectToken = PtyTicket.ConnectToken
|
||||
|
||||
export type Scope = {
|
||||
readonly ptyID: PtyID
|
||||
readonly ptyID: Pty.ID
|
||||
readonly directory?: string
|
||||
readonly workspaceID?: Workspace.ID
|
||||
}
|
||||
|
||||
@@ -145,8 +145,9 @@ function parts(input: string) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// cachePath makes each `:`-separated host part a directory.
|
||||
function safeHost(input: string) {
|
||||
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
|
||||
return Boolean(input) && !input.startsWith("-") && input.split(":").every(safeSegment)
|
||||
}
|
||||
|
||||
function safeSegment(input: string) {
|
||||
|
||||
@@ -50,15 +50,6 @@ export class StepFailedError extends Schema.TaggedError<StepFailedError>()("Sess
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInterruptedError extends Schema.TaggedError<UserInterruptedError>()(
|
||||
"Session.UserInterruptedError",
|
||||
{},
|
||||
) {
|
||||
override get message() {
|
||||
return "Session interrupted by user"
|
||||
}
|
||||
}
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { SessionRunner } from "./runner/index.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -51,9 +50,7 @@ type InterruptReason = "user" | "shutdown" | "inactivity"
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
|
||||
const failure = Cause.squash(exit.cause)
|
||||
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
|
||||
return { type: "failed" as const, error: toSessionError(failure) }
|
||||
return { type: "failed" as const, error: toSessionError(Cause.squash(exit.cause)) }
|
||||
}
|
||||
|
||||
/** Process-local execution: drains run in this process using the selected instance. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AIError } from "@opencode/ai"
|
||||
import { Context, Data, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import type { Promotable } from "../inbox.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError } from "../error.js"
|
||||
import { SessionRunnerModel } from "./model.js"
|
||||
import type { Instructions } from "../../instructions/index.js"
|
||||
|
||||
@@ -14,7 +14,6 @@ export type RunError =
|
||||
| MessageDecodeError
|
||||
| AgentNotFoundError
|
||||
| StepFailedError
|
||||
| UserInterruptedError
|
||||
| Instructions.InitializationBlocked
|
||||
|
||||
export type Continuation = { readonly step: number }
|
||||
|
||||
@@ -29,19 +29,6 @@ export class ModelUnavailableError extends Schema.TaggedError<ModelUnavailableEr
|
||||
return `Model unavailable: ${this.providerID}/${this.modelID}`
|
||||
}
|
||||
}
|
||||
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export const ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export type ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
|
||||
export type Resolved = ModelResolver.Resolved
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry.js"
|
||||
|
||||
import { AIError, isContextOverflowFailure } from "@opencode/ai"
|
||||
import { AIError, isRetryable } from "@opencode/ai"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
@@ -10,7 +10,8 @@ import type { PluginHooks } from "../../plugin/hooks.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { toSessionError } from "../to-session-error.js"
|
||||
|
||||
export { isRetryable }
|
||||
|
||||
interface Input {
|
||||
readonly cause: AIError
|
||||
@@ -27,43 +28,6 @@ export interface Decision {
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
export function isRetryable(error: AIError) {
|
||||
const override = error.reason.http?.headers["x-should-retry"]
|
||||
if (override === "true") return true
|
||||
if (override === "false") return false
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
|
||||
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
|
||||
case "Transport":
|
||||
return (
|
||||
error.reason.delivery !== "rejected" &&
|
||||
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
|
||||
)
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
// deterministic evidence, and transient failures are exactly the ones
|
||||
// that arrive in shapes no classifier anticipates.
|
||||
case "UnknownProvider":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidRequest":
|
||||
case "UnsupportedOperation":
|
||||
case "NoRoute":
|
||||
case "Timeout":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */
|
||||
const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
|
||||
|
||||
@@ -119,24 +83,6 @@ export const policy = (sessionID: SessionSchema.ID) =>
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
|
||||
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
|
||||
*/
|
||||
export const transient =
|
||||
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
|
||||
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
|
||||
Effect.retry(effect, {
|
||||
while: (cause) =>
|
||||
Effect.gen(function* () {
|
||||
if (isContextOverflowFailure(cause)) return false
|
||||
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
|
||||
if (!decision.retry) return false
|
||||
yield* Effect.sleep(decision.delay)
|
||||
return true
|
||||
}),
|
||||
})
|
||||
|
||||
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const decide = yield* policy(sessionID)
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Tool } from "@opencode/schema/tool"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
||||
import { AgentNotFoundError, StepFailedError } from "./error.js"
|
||||
import { ModelResolver } from "../model-resolver.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
@@ -48,18 +49,17 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
|
||||
}
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
|
||||
if (cause instanceof ModelResolver.UnsupportedCompactionError)
|
||||
return { type: "provider.unsupported-operation", message: cause.message }
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
if (
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
|
||||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
|
||||
cause instanceof SessionRunnerModel.ModelInitializationError ||
|
||||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
|
||||
cause instanceof ModelResolver.VariantUnavailableError ||
|
||||
cause instanceof ModelResolver.UnsupportedPackageError ||
|
||||
cause instanceof ModelResolver.ModelConfigurationError ||
|
||||
cause instanceof ModelResolver.ModelInitializationError ||
|
||||
cause instanceof ModelResolver.UnresolvedProviderVariablesError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Snapshot from "./snapshot.js"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { File } from "./file.js"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { Global } from "@opencode/util/global"
|
||||
@@ -58,7 +58,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
* Generate structured per-file diffs between two captured trees. `context`
|
||||
* controls unchanged lines around each unified diff hunk.
|
||||
*/
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly FileDiff.Info[], Error>
|
||||
|
||||
/**
|
||||
* Restore selected project-relative paths from their associated trees. A path
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolDefinition } from "@opencode/ai"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Cache, Effect, JsonPointer, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
import { $ZodType, toJSONSchema } from "zod/v4/core"
|
||||
|
||||
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
@@ -155,24 +155,7 @@ const validateStandard = (
|
||||
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (schema === undefined || schema === null) return {}
|
||||
if (isStandardSchema(schema)) return standardJsonSchema(schema, "input")
|
||||
if (!Schema.isSchema(schema)) return schema
|
||||
const json = toJsonSchema(schema)
|
||||
// Empty Effect structs describe non-null values, but tool parameters must be
|
||||
// objects. Normalize after inlining named schemas, retaining their metadata.
|
||||
if (
|
||||
schema.ast._tag === "Objects" &&
|
||||
schema.ast.propertySignatures.length === 0 &&
|
||||
schema.ast.indexSignatures.length === 0 &&
|
||||
schema.ast.encoding === undefined &&
|
||||
schema.ast.checks === undefined &&
|
||||
isRecord(json.not) &&
|
||||
json.not.type === "null" &&
|
||||
Object.keys(json.not).length === 1
|
||||
) {
|
||||
const { not: _, ...metadata } = json
|
||||
return { ...metadata, type: "object", properties: {}, additionalProperties: false }
|
||||
}
|
||||
return json
|
||||
return Schema.isSchema(schema) ? toJsonSchema(schema) : schema
|
||||
}
|
||||
|
||||
const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
@@ -187,7 +170,7 @@ const standardJsonSchema = (schema: StandardSchemaV1<any, any>, io: "input" | "o
|
||||
}
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
// Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it
|
||||
// without changing validation: `{ type: "integer", allOf: [{ minimum: 0 }] }` becomes
|
||||
// `{ type: "integer", minimum: 0 }` only when no keyword would be overwritten. Named schemas
|
||||
@@ -237,8 +220,8 @@ const inlineLocalReferences = (
|
||||
|
||||
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
|
||||
if (typeof value.$ref === "string" && localDefinitions) {
|
||||
const tokens = JsonPointer.parseUriFragment(value.$ref)
|
||||
const name = tokens?.length === 2 && tokens[0] === "$defs" ? tokens[1] : undefined
|
||||
const segment = value.$ref.match(/^#\/\$defs\/([^/]+)$/)?.[1]
|
||||
const name = segment?.replaceAll("~1", "/").replaceAll("~0", "~")
|
||||
if (name && !seen.has(name)) {
|
||||
const target = localDefinitions[name]
|
||||
if (target) {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function findLast<T>(
|
||||
items: readonly T[],
|
||||
predicate: (item: T, index: number, items: readonly T[]) => boolean,
|
||||
): T | undefined {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i]
|
||||
if (predicate(item, i, items)) return item
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { dlopen, read, type Pointer } from "bun:ffi"
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
export type LockResult =
|
||||
| { readonly acquired: true }
|
||||
| { readonly acquired: false; readonly held: true }
|
||||
| { readonly acquired: false; readonly held: false; readonly code: number }
|
||||
|
||||
const LOCK_EX = 2
|
||||
const LOCK_NB = 4
|
||||
const DARWIN_EWOULDBLOCK = 35
|
||||
const LINUX_EWOULDBLOCK = 11
|
||||
|
||||
export function lockDarwin(fd: number): LockResult {
|
||||
const library = dlopen("/usr/lib/libSystem.B.dylib", {
|
||||
flock: { args: ["i32", "i32"], returns: "i32" },
|
||||
__error: { args: [], returns: "ptr" },
|
||||
})
|
||||
try {
|
||||
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
|
||||
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
|
||||
if (result === 0) return { acquired: true }
|
||||
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
|
||||
return { acquired: false, held: false, code }
|
||||
} finally {
|
||||
library.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function lockLinux(fd: number): LockResult {
|
||||
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
|
||||
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
|
||||
flock: { args: ["i32", "i32"], returns: "i32" },
|
||||
__errno_location: { args: [], returns: "ptr" },
|
||||
})
|
||||
try {
|
||||
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
|
||||
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
|
||||
if (result === 0) return { acquired: true }
|
||||
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
|
||||
return { acquired: false, held: false, code }
|
||||
} finally {
|
||||
library.close()
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(pointer: Pointer | bigint | null) {
|
||||
if (pointer === null) throw new Error("Failed to read process lock error code")
|
||||
return read.i32(pointer, 0)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { dlopen, getInt32 } from "node:ffi"
|
||||
|
||||
export type LockResult =
|
||||
| { readonly acquired: true }
|
||||
| { readonly acquired: false; readonly held: true }
|
||||
| { readonly acquired: false; readonly held: false; readonly code: number }
|
||||
|
||||
const LOCK_EX = 2
|
||||
const LOCK_NB = 4
|
||||
const DARWIN_EWOULDBLOCK = 35
|
||||
const LINUX_EWOULDBLOCK = 11
|
||||
|
||||
export function lockDarwin(fd: number): LockResult {
|
||||
const library = dlopen("/usr/lib/libSystem.B.dylib", {
|
||||
flock: { arguments: ["int32", "int32"], return: "int32" },
|
||||
__error: { arguments: [], return: "pointer" },
|
||||
})
|
||||
try {
|
||||
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
|
||||
const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0)
|
||||
if (result === 0) return { acquired: true }
|
||||
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
|
||||
return { acquired: false, held: false, code }
|
||||
} finally {
|
||||
library.lib.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function lockLinux(fd: number): LockResult {
|
||||
const library = dlopen("libc.so.6", {
|
||||
flock: { arguments: ["int32", "int32"], return: "int32" },
|
||||
__errno_location: { arguments: [], return: "pointer" },
|
||||
})
|
||||
try {
|
||||
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
|
||||
const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0)
|
||||
if (result === 0) return { acquired: true }
|
||||
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
|
||||
return { acquired: false, held: false, code }
|
||||
} finally {
|
||||
library.lib.close()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export type LockResult =
|
||||
| { readonly acquired: true }
|
||||
| { readonly acquired: false; readonly held: true }
|
||||
| { readonly acquired: false; readonly held: false; readonly code: number }
|
||||
|
||||
// workerd has no FFI and no cross-process file locking; a Durable Object is
|
||||
// already single-threaded per instance, so nothing on this runtime should
|
||||
// reach these.
|
||||
const unavailable = (_fd: number): LockResult => {
|
||||
throw new Error("Process locks are unavailable on the workerd runtime")
|
||||
}
|
||||
|
||||
export const lockDarwin = unavailable
|
||||
export const lockLinux = unavailable
|
||||
@@ -1,131 +0,0 @@
|
||||
import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi"
|
||||
import { closeSync, mkdirSync, openSync } from "node:fs"
|
||||
import { connect, createServer, type Server, type Socket } from "node:net"
|
||||
import path from "node:path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Hash } from "@opencode/util/hash"
|
||||
|
||||
export namespace ProcessLock {
|
||||
export class HeldError extends Schema.TaggedError<HeldError>()("ProcessLockHeldError", {
|
||||
file: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Process lock is already held: ${this.file}`
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends Schema.TaggedError<SystemError>()("ProcessLockSystemError", {
|
||||
file: Schema.String,
|
||||
operation: Schema.Literals(["open", "acquire"]),
|
||||
code: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Process lock ${this.operation} failed for ${this.file}: ${this.code}`
|
||||
}
|
||||
}
|
||||
|
||||
export type LockError = HeldError | SystemError
|
||||
|
||||
const acquirePosix = Effect.fnUntraced(function* (file: string) {
|
||||
const fd = yield* Effect.try({
|
||||
try: () => {
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
return openSync(file, "a+", 0o600)
|
||||
},
|
||||
catch: (cause) =>
|
||||
new SystemError({
|
||||
file,
|
||||
operation: "open",
|
||||
code: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
})
|
||||
const result = yield* Effect.try({
|
||||
try: () => lock(fd),
|
||||
catch: (cause) =>
|
||||
new SystemError({
|
||||
file,
|
||||
operation: "acquire",
|
||||
code: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
closeSync(fd)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result.acquired) return fd
|
||||
closeSync(fd)
|
||||
return yield* result.held
|
||||
? new HeldError({ file })
|
||||
: new SystemError({ file, operation: "acquire", code: String(result.code) })
|
||||
})
|
||||
|
||||
export const acquire = Effect.fn("ProcessLock.acquire")(function* (file: string) {
|
||||
if (process.platform === "win32") {
|
||||
yield* Effect.acquireRelease(acquireWindows(file), closeWindows)
|
||||
return
|
||||
}
|
||||
yield* Effect.acquireRelease(acquirePosix(file), (fd) =>
|
||||
Effect.sync(() => {
|
||||
closeSync(fd)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function lock(fd: number): LockResult {
|
||||
if (process.platform === "darwin") return lockDarwin(fd)
|
||||
if (process.platform === "linux") return lockLinux(fd)
|
||||
throw new Error(`Unsupported process lock platform: ${process.platform}`)
|
||||
}
|
||||
|
||||
function acquireWindows(file: string) {
|
||||
return Effect.callback<Server, ProcessLock.LockError>((resume) => {
|
||||
const server = createServer()
|
||||
let probe: Socket | undefined
|
||||
const pipe = `\\\\.\\pipe\\opencode-process-lock-${Hash.sha256(path.resolve(file).toLowerCase())}`
|
||||
const onError = (cause: NodeJS.ErrnoException) => {
|
||||
server.off("listening", onListening)
|
||||
probe = connect(pipe)
|
||||
const onProbeError = () => {
|
||||
probe?.off("connect", onConnect)
|
||||
resume(
|
||||
Effect.fail(
|
||||
new ProcessLock.SystemError({
|
||||
file,
|
||||
operation: "acquire",
|
||||
code: cause.code ?? cause.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
const onConnect = () => {
|
||||
probe?.off("error", onProbeError)
|
||||
probe?.destroy()
|
||||
resume(Effect.fail(new ProcessLock.HeldError({ file })))
|
||||
}
|
||||
probe.once("connect", onConnect)
|
||||
probe.once("error", onProbeError)
|
||||
}
|
||||
const onListening = () => {
|
||||
server.off("error", onError)
|
||||
resume(Effect.succeed(server))
|
||||
}
|
||||
server.once("error", onError)
|
||||
server.once("listening", onListening)
|
||||
server.on("connection", (socket) => socket.destroy())
|
||||
server.listen(pipe)
|
||||
return Effect.sync(() => {
|
||||
probe?.destroy()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function closeWindows(server: Server) {
|
||||
return Effect.callback<void>((resume) => {
|
||||
if (!server.listening) return resume(Effect.void)
|
||||
server.close((error) => resume(error ? Effect.die(error) : Effect.void))
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { ConfigProviderOptionsV1 } from "./provider-options.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { Model } from "../../model.js"
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
|
||||
@@ -11,8 +11,9 @@ export type Object = Schema.Schema.Type<typeof Object>
|
||||
export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" })
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
// Known permission keys get explicit types in the Effect schema for generated docs/types.
|
||||
// StructWithRest also accepts custom permission names.
|
||||
// Known permission keys get explicit types in the Effect schema for generated
|
||||
// docs/types. Runtime config parsing uses Effect's `propertyOrder: "original"`
|
||||
// parse option so user key order is preserved for permission precedence.
|
||||
const InputObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
read: Schema.optional(Rule),
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { Arbitrary } from "effect/unstable/arbitrary"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode/schema/config"
|
||||
import { ConfigModel } from "@opencode/schema/config/model"
|
||||
@@ -632,22 +632,17 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
const result = Effect.runSync(
|
||||
Arbitrary.checkEffect(
|
||||
Arbitrary.schema(ConfigV1.Info),
|
||||
(info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
return true
|
||||
},
|
||||
{ runs: 100 },
|
||||
),
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
expect(Arbitrary.formatCheckFailure(result)).toBeUndefined()
|
||||
}, 30_000)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { Arbitrary } from "effect/unstable/arbitrary"
|
||||
import { Duration, Schema } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { ConfigNormalize } from "@opencode/core/config/normalize"
|
||||
import { Info } from "@opencode/schema/config"
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
|
||||
function normalized(input: unknown) {
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
@@ -80,21 +80,16 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
|
||||
test("preserves arbitrary JSON-round-tripped native configuration", () => {
|
||||
const result = Effect.runSync(
|
||||
Arbitrary.checkEffect(
|
||||
Arbitrary.schema(Info),
|
||||
(info) => {
|
||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
||||
const result = normalized(source)
|
||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
||||
)
|
||||
return true
|
||||
},
|
||||
{ runs: 100 },
|
||||
),
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(Info)(FastCheck), (info) => {
|
||||
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
|
||||
const result = normalized(source)
|
||||
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
|
||||
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
|
||||
)
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
expect(Arbitrary.formatCheckFailure(result)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("merges named maps by entry and gives valid native entries precedence", () => {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ProcessLock } from "@opencode/core/util/process-lock"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
|
||||
const input = Schema.decodeUnknownSync(
|
||||
Schema.fromJsonString(Schema.Struct({ file: Schema.String, ready: Schema.String })),
|
||||
)(process.argv[2])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* ProcessLock.acquire(input.file)
|
||||
yield* Effect.promise(() => fs.writeFile(input.ready, String(process.pid)))
|
||||
return yield* Effect.never
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -5,13 +5,12 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Pty } from "@opencode/core/pty"
|
||||
import { PtyID } from "@opencode/core/pty/schema"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { ShellSelect } from "@opencode/core/shell/select"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
|
||||
type PtyEvent = { type: "created" | "exited" | "deleted"; id: Pty.ID }
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -46,7 +45,7 @@ const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: stri
|
||||
)
|
||||
})
|
||||
|
||||
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
|
||||
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: Pty.ID, count: number) =>
|
||||
Effect.gen(function* () {
|
||||
const picked: Array<PtyEvent["type"]> = []
|
||||
while (picked.length < count) {
|
||||
@@ -61,7 +60,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
|
||||
}),
|
||||
)
|
||||
|
||||
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
|
||||
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: Pty.ID, cursor?: number) {
|
||||
const pty = yield* Pty.Service
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
@@ -90,7 +89,7 @@ describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = PtyID.make("pty_missing")
|
||||
const id = Pty.ID.make("pty_missing")
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { PtyID } from "@opencode/core/pty/schema"
|
||||
import { Pty } from "@opencode/core/pty"
|
||||
import { PtyTicket } from "@opencode/core/pty/ticket"
|
||||
import { Workspace } from "@opencode/core/workspace"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -17,7 +17,7 @@ describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
|
||||
const scope = { ptyID: Pty.ID.ascending(), directory: "/tmp/a" }
|
||||
const issued = yield* tickets.issue(scope)
|
||||
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
|
||||
@@ -28,7 +28,7 @@ describe("PTY websocket tickets", () => {
|
||||
it.live("rejects tickets scoped to a different request", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const ptyID = Pty.ID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
|
||||
@@ -39,7 +39,7 @@ describe("PTY websocket tickets", () => {
|
||||
itExpiring.live("rejects tickets after the TTL elapses", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const ptyID = Pty.ID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID })
|
||||
|
||||
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
|
||||
@@ -51,7 +51,7 @@ describe("PTY websocket tickets", () => {
|
||||
it.live("rejects tickets scoped to a different workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const ptyID = Pty.ID.ascending()
|
||||
const workspaceID = Workspace.ID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, workspaceID })
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ describe("Repository", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("caches a host with a port under a directory per host part", () => {
|
||||
expect(Repository.cachePath("/cache", Repository.parseRemote("ssh://git@example.com:2222/owner/repo"))).toBe(
|
||||
path.join("/cache", "example.com", "2222", "owner", "repo"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps local file repositories distinct from remote repositories", () => {
|
||||
const localPath = path.resolve("repo.git")
|
||||
const reference = Repository.parse(pathToFileURL(localPath).href)
|
||||
@@ -62,6 +68,18 @@ describe("Repository", () => {
|
||||
expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError)
|
||||
})
|
||||
|
||||
test.each([
|
||||
"..:repo",
|
||||
"git@..:repo",
|
||||
"../owner/repo",
|
||||
"ssh://../repo",
|
||||
"ssh://..:22/repo",
|
||||
"https://%2e%2e/owner/repo",
|
||||
".:repo",
|
||||
])("rejects %s because its host contains a relative path segment", (input) => {
|
||||
expect(() => Repository.parseRemote(input)).toThrow(Repository.InvalidReferenceError)
|
||||
})
|
||||
|
||||
test("compares cache identity independent of input spelling", () => {
|
||||
const shorthand = Repository.parseRemote("owner/repo")
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionRestart } from "@opencode/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode/core/session/error"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
@@ -50,10 +49,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
const interrupted = Effect.runSyncExit(Effect.interrupt)
|
||||
expect(SessionExecution.terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
|
||||
expect(SessionExecution.terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
|
||||
expect(SessionExecution.terminal(Exit.fail(new UserInterruptedError()))).toEqual({
|
||||
type: "interrupted",
|
||||
reason: "user",
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("the sweep only lists claimed top-level Sessions", () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { State } from "@opencode/core/state"
|
||||
import { Effect } from "effect"
|
||||
import FastCheck from "fast-check"
|
||||
import { FastCheck } from "effect/testing"
|
||||
|
||||
type Operation = { multiply: number; add: number }
|
||||
type Value = { value: number; order: number[] }
|
||||
|
||||
@@ -1063,7 +1063,7 @@ describe("Tool", () => {
|
||||
value: Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => String(value)),
|
||||
encode: SchemaGetter.transformEffect((value) =>
|
||||
encode: SchemaGetter.transformOrFail((value) =>
|
||||
value === "valid"
|
||||
? Effect.succeed(true)
|
||||
: Effect.fail(new SchemaIssue.InvalidValue({ message: "invalid output" }, value)),
|
||||
|
||||
@@ -67,62 +67,6 @@ test("Effect tool schemas use exact optional keys and flatten compatible constra
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "plain", input: Schema.Struct({}), metadata: {} },
|
||||
{
|
||||
name: "annotated",
|
||||
input: Schema.Struct({}).annotate({ identifier: "Empty Input", description: "No parameters" }),
|
||||
metadata: { description: "No parameters" },
|
||||
},
|
||||
])("empty $name Effect input structs describe callable object parameters", async ({ input, metadata }) => {
|
||||
const tool: Info = {
|
||||
name: "empty",
|
||||
description: "Empty tool",
|
||||
input,
|
||||
output: input,
|
||||
execute: (value) => Effect.succeed({ output: value }),
|
||||
}
|
||||
expect(definition(tool).inputSchema).toEqual({
|
||||
...metadata,
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
})
|
||||
expect(definition(tool).outputSchema).toEqual({ ...metadata, not: { type: "null" } })
|
||||
expect(await Effect.runPromise(execute(tool, {}, context))).toMatchObject({ output: {} })
|
||||
})
|
||||
|
||||
test("empty input normalization preserves explicit Effect overrides and raw JSON schemas", () => {
|
||||
for (const override of [{ type: "object", additionalProperties: true }, { not: { type: "null" } }]) {
|
||||
const tool: Info = {
|
||||
name: "explicit",
|
||||
description: "Explicit schema tool",
|
||||
input: override,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
expect(definition(tool).inputSchema).toEqual(override)
|
||||
const checked = Schema.Struct({}).check(Schema.makeFilter(() => true, { toJsonSchema: () => override }))
|
||||
expect(definition({ ...tool, output: checked }).outputSchema).toEqual(
|
||||
definition({ ...tool, input: checked }).inputSchema,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("empty input normalization leaves nested empty structs unchanged", () => {
|
||||
const tool: Info = {
|
||||
name: "nested",
|
||||
description: "Nested empty struct",
|
||||
input: Schema.Struct({ value: Schema.Struct({}) }),
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
expect(definition(tool).inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { value: { not: { type: "null" } } },
|
||||
required: ["value"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("Effect tool schemas inline named child schemas", () => {
|
||||
const Child = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Child" })
|
||||
const tool: Info = {
|
||||
@@ -162,33 +106,6 @@ test("Effect tool schemas resolve escaped definition names", () => {
|
||||
expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$defs")
|
||||
})
|
||||
|
||||
test.each(["Lookup Input", "café", "A/B~C% D#E"])(
|
||||
"Effect tool schemas inline URI-encoded definition %s",
|
||||
(identifier) => {
|
||||
const child = Schema.Struct({ city: Schema.String }).annotate({ identifier })
|
||||
const tool: Info = {
|
||||
name: "encoded-reference",
|
||||
description: "Encoded reference",
|
||||
input: child,
|
||||
output: Schema.Struct({ result: child }),
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
const expected = {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
expect(definition(tool).inputSchema).toEqual(expected)
|
||||
expect(definition(tool).outputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: { result: expected },
|
||||
required: ["result"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("portable schemas validate and describe typed tools", async () => {
|
||||
const input = {
|
||||
"~standard": {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { ProcessLock } from "@opencode/core/util/process-lock"
|
||||
import { Effect } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const worker = path.join(import.meta.dir, "../fixture/process-lock-worker.ts")
|
||||
|
||||
it.live(
|
||||
"releases ownership when the scope closes",
|
||||
Effect.gen(function* () {
|
||||
const root = yield* temp("opencode-process-lock-")
|
||||
const file = path.join(root, "service.lock")
|
||||
yield* Effect.scoped(ProcessLock.acquire(file))
|
||||
yield* Effect.scoped(ProcessLock.acquire(file))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"releases ownership when the process dies",
|
||||
Effect.gen(function* () {
|
||||
const root = yield* temp("opencode-process-lock-death-")
|
||||
const file = path.join(root, "service.lock")
|
||||
const ready = path.join(root, "ready")
|
||||
const child = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.spawn([process.execPath, worker, JSON.stringify({ file, ready })], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
),
|
||||
(child) =>
|
||||
Effect.promise(async () => {
|
||||
kill(child)
|
||||
await child.exited
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
for (let attempt = 0; attempt < 100 && !(await Bun.file(ready).exists()); attempt++) await Bun.sleep(20)
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(ready).exists())).toBe(true)
|
||||
|
||||
const error = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip)
|
||||
expect(error._tag).toBe("ProcessLockHeldError")
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
process.kill(child.pid, "SIGSTOP")
|
||||
const paused = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip)
|
||||
expect(paused._tag).toBe("ProcessLockHeldError")
|
||||
process.kill(child.pid, "SIGCONT")
|
||||
}
|
||||
|
||||
kill(child)
|
||||
yield* Effect.promise(() => child.exited)
|
||||
yield* Effect.scoped(ProcessLock.acquire(file))
|
||||
}),
|
||||
)
|
||||
|
||||
function temp(prefix: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), prefix))),
|
||||
(root) => Effect.promise(() => fs.rm(root, { recursive: true, force: true })),
|
||||
)
|
||||
}
|
||||
|
||||
function kill(child: Bun.Subprocess) {
|
||||
if (process.platform === "win32") return child.kill()
|
||||
return child.kill("SIGKILL")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user