Compare commits

..
Author SHA1 Message Date
Shoubhit Dash ef062be96c test(core): add snapshot parity and benchmark scripts 2026-09-29 09:04:48 +05:30
Shoubhit Dash 2e3b4408cf perf(core): speed up and harden snapshot capture 2026-09-29 09:04:48 +05:30
Aiden Cline ca084b2430 fix(ai): give provider routes distinct IDs (#51976) 2026-09-28 19:55:06 -05:00
Aiden Cline 3740ec311b feat(core): align shell tool environment with agent conventions (#51975) 2026-09-28 19:53:53 -05:00
opencode-agent[bot]andrekram1-node 35bd8ac442 test(core): update xAI variant expectations for Responses (#51977)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:48:35 -05:00
Aiden Cline 1fc05ca590 fix(core): share affinity in provider session headers (#51931) 2026-09-28 19:30:24 -05:00
Aiden Cline bda798b167 fix(core): drop session ID and order instructions for prompt-cache reuse (#51960) 2026-09-28 19:29:28 -05:00
Aiden Cline 3babae35c0 fix(core): fall back to Cloudflare environment IDs when not configured (#51963) 2026-09-28 19:28:03 -05:00
Aiden Cline 3ab5c1433c fix(core): request xAI reasoning summaries on Responses variants (#51964) 2026-09-28 19:11:48 -05:00
Aiden Cline 49437a25b1 fix(ai): classify invalid Google API keys as authentication errors (#51950) 2026-09-28 18:49:29 -05:00
Aiden Cline 49403a554f feat(core): cap requested output tokens at 256k (#51962) 2026-09-28 18:49:05 -05:00
opencode-agent[bot]andrekram1-node 87d6f93409 test(cli): isolate run exit codes between tests (#51955)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:16:01 -04:00
Frank 97d4eaa2ba zen: jev privacy policy 2026-09-28 19:01:46 -04:00
Kit Langton 7827dbe396 fix(tui): deduplicate projects in the open picker (#51924) 2026-09-28 18:13:00 -04:00
James Long 5f9ced439b fix(cli): drop misleading interruption errors after declined prompts in run (#51948) 2026-09-28 17:58:04 -04:00
Aiden Cline 8c1ce954d0 fix(tui): wait for agent and model before auto-submitting --prompt (#51938) 2026-09-28 15:41:14 -05:00
07338c5d48 feat(cli): create the session when --session names one that does not exist (#51405)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-28 15:09:03 -05:00
James Long 46e53e3f2b fix(ai): expose evaluation confidence (#51930) 2026-09-28 15:24:21 -04:00
Aiden Cline 6cf442b545 fix(core): share child session affinity headers (#51923) 2026-09-28 13:31:17 -05:00
opencode-agent[bot]andrekram1-node f20f5b68ee chore(release): announce V2 releases in Discord (#51880)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 12:30:55 -05:00
SebastianandOpenCode Agent 96dd9f77a9 fix(core): attribute one-shot generation requests (#48358)
Co-authored-by: OpenCode Agent <opencode-agent[bot]@users.noreply.github.com>
2026-09-28 11:55:14 -05:00
opencode-agent[bot] dd786c62af chore(core): refresh bundled models.dev snapshot 2026-09-28 12:23:50 +00:00
Frank 87c402a124 docs(go): simplify v2 usage limit explanation 2026-09-28 07:39:42 -04:00
Frank 7076a878a4 docs(www): document Go Plus (#51834) 2026-09-28 07:25:37 -04:00
Frank 45b91eed82 docs(go): sync v2 model list with v1 (#51837) 2026-09-28 11:23:40 +00:00
Niels Kootstra 39e1ce55bc fix(core): reject relative path segments in repository hosts (#51577) 2026-09-28 10:14:35 +05:30
opencode-agent[bot]andrekram1-node d9f54392ba fix(tui): distinguish background shell from interrupted command (#51769)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-27 23:01:00 -05:00
Aiden Cline d73396ab3d fix(ai): preserve Gemini thought signatures on OpenAI Chat tool calls (#51768) 2026-09-27 22:57:22 -05:00
Jérôme BenoitandTest User 0caae608a2 chore(nix): update nixpkgs for Bun 1.4 (#50221)
Co-authored-by: Test User <test@test.com>
2026-09-27 21:34:52 -05:00
Kit Langton 96f23508be refactor(core): remove unused project discovery option (#51729) 2026-09-27 15:05:46 -07:00
Kit Langton 28bb0a7158 refactor(core): drop forwarding shim modules (#51670) 2026-09-27 14:47:19 -07:00
DS 3d109828ff fix(tui): truncate btw question preview (#51713) 2026-09-27 21:05:20 +02:00
Shoubhit Dash c0d49f101c feat(ai): retry transient failures on queued generation reads (#51635) 2026-09-27 19:47:25 +05:30
Shoubhit Dash be2446e188 feat(ai): add ElevenLabs Scribe transcription route (#51641) 2026-09-27 19:34:36 +05:30
Shoubhit Dash 107966eddd fix(ai): reject and throw with signal.reason on abort (#51633) 2026-09-27 19:24:45 +05:30
Kit Langton f5e580cde1 chore(core): remove dead modules and exports (#51667) 2026-09-27 06:38:43 -07:00
Shoubhit Dash 4428a77acd fix(ai): classify terminal generation failures by provider error code (#51632) 2026-09-27 18:44:21 +05:30
210 changed files with 5481 additions and 2305 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode/core": patch
---
Correct directory page headings when the read offset is zero.
+20
View File
@@ -24,6 +24,10 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -653,3 +657,19 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+34 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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
+24 -12
View File
@@ -129,8 +129,9 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -752,7 +753,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 +777,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 +877,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 +894,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 +904,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 +928,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:
+27 -17
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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({
+1
View File
@@ -39,6 +39,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"anthropic-compatible-messages",
"google-vertex-messages",
"bedrock-converse",
"openrouter",
@@ -63,6 +63,7 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -70,6 +71,7 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -92,6 +94,7 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+10 -5
View File
@@ -142,32 +142,37 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
+47 -28
View File
@@ -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),
}),
})
+1 -1
View File
@@ -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,
+7 -6
View File
@@ -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
+14 -1
View File
@@ -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.
+26 -9
View File
@@ -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)
+6 -1
View File
@@ -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: [] }
+8
View File
@@ -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")
+47 -1
View File
@@ -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([
@@ -68,7 +109,8 @@ const QUOTA_CODES = new Set([
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -218,6 +260,10 @@ function providerCodes(value: unknown) {
error?.type,
error?.status,
error?.error_type,
// Google `google.rpc.ErrorInfo` details carry the specific reason.
...(Array.isArray(error?.details)
? error.details.map((detail) => (isRecord(detail) ? detail.reason : undefined))
: []),
inner?.code,
metadata?.error_type,
responseError?.code,
@@ -28,7 +28,8 @@ export type Settings = ProviderPackage.Settings &
readonly provider?: string
}
export const routes = [AnthropicMessages.route]
const compatibleRoute = AnthropicMessages.route.with({ id: "anthropic-compatible-messages", provider: id })
export const routes = [compatibleRoute]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
@@ -43,7 +44,7 @@ export const configure = (input: Config) => {
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
const route = (provider === "anthropic" ? AnthropicMessages.route : compatibleRoute).with({
...rest,
provider,
endpoint: { baseURL },
+5
View File
@@ -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
+3 -3
View File
@@ -35,13 +35,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
id: "xai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponsesChannel.transport({
id: "openai-responses",
id: "xai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
@@ -53,7 +53,7 @@ const responsesRoute = Route.make({
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
id: "xai-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
+1 -1
View File
@@ -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`))
}),
+26 -5
View File
@@ -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(
+34 -4
View File
@@ -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
View File
@@ -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 }
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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" })
+21 -1
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -107,6 +107,26 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model(
"compatible",
),
system: "You are concise.",
prompt: "hi",
}),
)
expect(prepared.route).toBe("anthropic-compatible-messages")
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+2 -1
View File
@@ -39,17 +39,18 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
+2
View File
@@ -26,8 +26,10 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
+7 -2
View File
@@ -154,8 +154,8 @@ describe("public exports", () => {
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
expect(OpenAI.provider.image).toBe(OpenAI.image)
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
@@ -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", () => {
@@ -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}"
}
}
]
}
@@ -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}"
}
}
]
}
@@ -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"
}
}
]
}
+85 -1
View File
@@ -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")
+26
View File
@@ -355,6 +355,32 @@ describe("provider error rawBody classification", () => {
}
})
test("classifies Google invalid API keys as authentication failures", () => {
const rawBody = JSON.stringify({
error: {
code: 400,
message: "API key not valid. Please pass a valid API key.",
status: "INVALID_ARGUMENT",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "API_KEY_INVALID",
domain: "googleapis.com",
},
{
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
locale: "en-US",
message: "API key not valid. Please pass a valid API key.",
},
],
},
})
expect(
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
._tag,
).toBe("Authentication")
})
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
+4 -1
View File
@@ -300,7 +300,9 @@ describe("provider package entrypoints", () => {
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.id).toBe("anthropic-compatible-messages")
expect(selected.route.protocol).toBe("anthropic-messages")
expect(selected.route.providerMetadataKey).toBe("example")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://messages.example.test/v1",
})
@@ -319,6 +321,7 @@ describe("provider package entrypoints", () => {
thinking: { type: "adaptive" },
})
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -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"]))
}),
)
})
@@ -92,5 +92,6 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
})
@@ -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(
@@ -56,7 +56,9 @@ describe("xAI Responses route", () => {
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.route).toBe("xai-responses")
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
@@ -298,3 +300,14 @@ describe("xAI Responses route", () => {
}),
)
})
it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }),
)
expect(prepared.route).toBe("xai-chat")
expect(prepared.protocol).toBe("openai-chat")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
}),
)
+2 -127
View File
@@ -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",
+113 -1
View File
@@ -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
View File
@@ -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()
+2 -2
View File
@@ -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(
+4 -4
View File
@@ -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"])),
+96 -98
View File
@@ -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.withDescription("Session ID to continue, or to create if it does not exist"),
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.withDescription("Session ID to continue, or to create if it does not exist"),
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.withDescription("Session ID to continue, or to create if it does not exist"),
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)),
},
}),
],
+15 -1
View File
@@ -11,6 +11,10 @@ import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { findSession } from "../../session-target"
import { errorMessage } from "../../util/error"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -46,6 +50,15 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const session = Option.getOrUndefined(input.session)
// A missing --session ID becomes the ID of the session the first prompt creates.
const sessionExists =
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
@@ -81,7 +94,8 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+2 -2
View File
@@ -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),
)
+7 -2
View File
@@ -135,6 +135,7 @@ export async function runNonInteractivePrompt(input: Input) {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -163,6 +164,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
if (input.compatibility !== "v1") process.exitCode = 1
}
const consume = async () => {
@@ -493,7 +495,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -620,7 +623,9 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
// A declined tool call ends its step with an interrupted-step error that is
// only a consequence of our own rejection; it was already reported above.
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
@@ -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
+13 -8
View File
@@ -63,6 +63,7 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -101,14 +102,11 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
if (input.session && !explicit) {
if (input.fork) throw new Error("Session not found")
return { session: undefined }
}
if (explicit)
return {
session: input.fork
@@ -133,6 +131,13 @@ async function selectSession(input: {
}
}
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
throw error
})
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
+7 -7
View File
@@ -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(" "),
@@ -309,6 +309,7 @@ async function capture(input: Parameters<typeof run>[0]) {
afterEach(() => {
mock.restore()
process.exitCode = 0
})
describe("runNonInteractivePrompt", () => {
@@ -433,6 +434,7 @@ describe("runNonInteractivePrompt", () => {
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
expect(process.exitCode).toBe(1)
})
test("attach mode cancels only session-owned forms", async () => {
@@ -448,6 +450,7 @@ describe("runNonInteractivePrompt", () => {
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
expect(process.exitCode).toBe(1)
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
+1 -2
View File
@@ -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,
+22
View File
@@ -42,6 +42,28 @@ describe("session target resolver", () => {
})
})
test("creates a missing explicit Session with its ID", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
})
test("does not create a missing explicit Session to fork", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
const create = spyOn(client.session, "create")
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
"Session not found",
)
expect(create).not.toHaveBeenCalled()
})
test("paginates to continue the exact directory", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
+1 -1
View File
@@ -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": {
+15 -21
View File
@@ -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
+6 -144
View File
@@ -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",
+1 -8
View File
@@ -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",
@@ -0,0 +1,64 @@
/**
* Copy existing snapshot stores and run the real compaction on the copies. Verifies
* that every locally stored object survives and reports size, time, and peak RSS
* of the Git child. The originals are only read.
*
* bun run script/benchmark-snapshot-compact.ts <store>...
*/
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Logger } from "effect"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Git } from "../src/git"
import { AbsolutePath } from "../src/schema"
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-compact")
await fs.mkdir(root, { recursive: true })
// Local objects only: alternates are hidden so borrowed source objects do not count.
async function objects(store: string) {
const alternates = path.join(store, "objects", "info", "alternates")
const borrowed = await fs.readFile(alternates, "utf8").catch(() => undefined)
if (borrowed !== undefined) await fs.rm(alternates)
const listed = await $`git --git-dir ${store} --work-tree ${store} cat-file --batch-all-objects ${"--batch-check=%(objectname)"}`
.quiet()
.text()
if (borrowed !== undefined) await fs.writeFile(alternates, borrowed)
return listed.split("\n").filter(Boolean).toSorted()
}
const size = async (directory: string) => Number((await $`du -sk ${directory}`.quiet().text()).split("\t")[0])
const loose = async (store: string) =>
(await $`find ${path.join(store, "objects")} -path '*/objects/??/*' -type f`.quiet().text()).split("\n").filter(Boolean)
.length
let failed = false
for (const source of process.argv.slice(2)) {
const copy = await fs.mkdtemp(path.join(root, "store-"))
await $`cp -R ${source}/. ${copy}`.quiet()
const before = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) }
const started = performance.now()
await Effect.runPromise(
Effect.gen(function* () {
const git = yield* Git.Service
yield* git.objects.compact(
new Git.Repository({
worktree: AbsolutePath.make(copy),
gitDirectory: AbsolutePath.make(copy),
commonDirectory: AbsolutePath.make(copy),
}),
)
}).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))),
)
const elapsed = performance.now() - started
const after = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) }
const same = before.objects.join("\n") === after.objects.join("\n")
if (!same) failed = true
console.log(
`${path.basename(source)} ${before.size} KiB -> ${after.size} KiB loose ${before.loose} -> ${after.loose} objects ${before.objects.length} -> ${after.objects.length} ${same ? "identical" : "MISMATCH"} ${elapsed.toFixed(0)} ms`,
)
await fs.rm(copy, { recursive: true, force: true })
}
if (failed) process.exit(1)
@@ -0,0 +1,194 @@
/**
* Drives the real SessionStep.attempt through a scripted agent trace against a real
* snapshot store, then repeats it with snapshots disabled. The difference is the
* wall-clock cost snapshots add to the session.
*
* bun run script/benchmark-snapshot-session.ts [--fixture opencode] [--ttft 400] [--rounds 2]
*
* Uses the fixtures generated by benchmark-snapshot.ts.
*/
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { LanguageModel, LLM, LLMEvent } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
import { TestLLM } from "@opencode/ai/testing"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Logger, Stream } from "effect"
import { Agent } from "../src/agent"
import { Bus } from "../src/bus"
import { Database } from "../src/database/database"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Location } from "../src/location"
import { Project } from "../src/project"
import { ProjectTable } from "../src/project/sql"
import { AbsolutePath } from "../src/schema"
import { Session } from "../src/session"
import { SessionMessage } from "../src/session/message"
import { SessionProjector } from "../src/session/projector"
import { SessionRunnerModel } from "../src/session/runner/model"
import { SessionStep } from "../src/session/runner/step"
import { SessionTable } from "../src/session/sql"
import { Snapshot } from "../src/snapshot"
import { ToolOutput } from "../src/tool-output"
const args = process.argv.slice(2)
const flag = (name: string) => {
const index = args.indexOf(`--${name}`)
return index === -1 ? undefined : args[index + 1]
}
const fixture = flag("fixture") ?? "opencode"
const ttft = Number(flag("ttft") ?? 400)
const rounds = Number(flag("rounds") ?? 2)
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench")
const directory = path.join(root, "fixtures", fixture)
// A read-heavy coding loop: explore, edit, run checks, answer.
const trace = [
"read",
"grep",
"read",
"glob",
"read",
"edit",
"shell",
"read",
"edit",
"shell",
"grep",
"read",
"edit",
"shell",
"text",
] as const
let spawns = 0
const countingProcess = Layer.effect(
AppProcess.Service,
Effect.gen(function* () {
const real = yield* AppProcess.Service
return AppProcess.Service.of({
...real,
run: (command, options) => {
spawns++
return real.run(command, options)
},
})
}),
).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node)))
const snapshotLayer = (data: string) =>
AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
AppProcess.node.replace(countingProcess),
])
const files = (await $`git ls-files -z -- '*.ts'`.cwd(directory).text()).split("\0").filter(Boolean).slice(0, 50)
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "bench-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
},
)
const delayed = (response: ReturnType<typeof TestLLM.stop>) =>
Stream.unwrap(Effect.sleep(ttft).pipe(Effect.as(Stream.fromIterable(response as Iterable<LLMEvent>))))
const run = (snapshots: Layer.Layer<Snapshot.Service>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Test
const sessionID = Session.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make(directory), sandboxes: [] })
.onConflictDoNothing()
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "bench", directory, version: "bench" })
.run()
const steps = yield* SessionStep.make
const snapshot = yield* Snapshot.Service
// Warm the snapshot store so the first step does not pay repository creation.
yield* snapshot.capture()
const before = spawns
const start = performance.now()
let edits = 0
for (let round = 0; round < rounds; round++)
for (const kind of trace) {
const call = `call-${round}-${edits}-${kind}-${Math.random().toString(36).slice(2)}`
yield* llm.push(
delayed(
kind === "text"
? TestLLM.text("Done.", `text-${call}`)
: TestLLM.tool(call, kind, { path: files[edits % files.length] }),
),
)
yield* steps.attempt({
isLocationClosed: () => false,
sessionID,
assistantMessageID: SessionMessage.ID.create(),
agent: Agent.defaultID,
model,
prepared: {
retry: () => Effect.void,
request: LLM.request({ model: model.model, prompt: "bench" }),
options: {},
executeTool: (input) =>
Effect.gen(function* () {
if (input.call.name === "edit")
yield* Effect.promise(() =>
Bun.write(path.join(directory, files[edits++ % files.length]!), `// bench ${edits}\n`),
)
return { content: [{ type: "text" as const, text: "ok" }] }
}),
},
retry: () => Effect.succeed({ retry: false as const }),
recoverContinuation: false,
recoverOverflow: Effect.succeed(false),
})
}
return { ms: performance.now() - start, spawns: spawns - before }
}).pipe(
Effect.provide(snapshots),
Effect.provide(
Layer.merge(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
]),
TestLLM.testLayer(),
),
),
Effect.provide(Logger.layer([])),
)
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
const data = await fs.mkdtemp(path.join(root, "session-data-"))
const noop = await Effect.runPromise(run(Snapshot.noopLayer))
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
const real = await Effect.runPromise(run(snapshotLayer(data)))
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
await fs.rm(data, { recursive: true, force: true })
const count = trace.length * rounds
console.log(`fixture ${fixture}, ${count} steps, simulated time to first event ${ttft} ms`)
console.log(`no snapshots ${noop.ms.toFixed(0).padStart(7)} ms`)
console.log(`snapshots ${real.ms.toFixed(0).padStart(7)} ms git ${real.spawns}`)
console.log(
`overhead ${(real.ms - noop.ms).toFixed(0).padStart(7)} ms ${((real.ms - noop.ms) / count).toFixed(1)} ms/step ${(real.spawns / count).toFixed(1)} git/step`,
)
@@ -0,0 +1,28 @@
// Child process for the cross-process snapshot probe: edits its own file and captures repeatedly,
// printing the number of failed captures.
import path from "path"
import { Effect, Logger } from "effect"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Location } from "../src/location"
import { AbsolutePath } from "../src/schema"
import { Snapshot } from "../src/snapshot"
import { Global } from "@opencode/util/global"
const [data, directory, id, count] = process.argv.slice(2)
const layer = AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory!) }))),
Global.node.replace(Global.layerWith({ data: data!, config: path.join(data!, "config") })),
])
const failures = await Effect.runPromise(
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
let failed = 0
for (let index = 0; index < Number(count); index++) {
yield* Effect.promise(() => Bun.write(path.join(directory!, `worker-${id}.txt`), `${index}\n`))
if ((yield* snapshot.capture()) === undefined) failed++
}
return failed
}).pipe(Effect.provide(layer), Effect.provide(Logger.layer([]))),
)
console.log(failures)
+385
View File
@@ -0,0 +1,385 @@
/**
* Snapshot benchmark + robustness probe.
*
* bun run script/benchmark-snapshot.ts [--fixtures small,medium,large,opencode] [--iterations 10] [--json out.json]
*
* Fixtures are generated once under $TMPDIR/opencode-snapshot-bench and reset before each run.
* Every timed scenario also reports how many git processes it spawned.
*/
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Git } from "../src/git"
import { Location } from "../src/location"
import { AbsolutePath, RelativePath } from "../src/schema"
import { Snapshot } from "../src/snapshot"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
const args = process.argv.slice(2)
const flag = (name: string) => {
const index = args.indexOf(`--${name}`)
return index === -1 ? undefined : args[index + 1]
}
const iterations = Number(flag("iterations") ?? 10)
const selected = (flag("fixtures") ?? "small,medium,large,opencode").split(",")
const jsonOut = flag("json")
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench")
type Fixture = { name: string; tracked: number; ignored: number; dirs: number }
const fixtures: Record<string, Fixture> = {
small: { name: "small", tracked: 1_000, ignored: 0, dirs: 20 },
medium: { name: "medium", tracked: 20_000, ignored: 20_000, dirs: 400 },
large: { name: "large", tracked: 100_000, ignored: 50_000, dirs: 2_000 },
}
// ---------- fixture generation ----------
async function writeMany(files: Array<[string, string]>) {
const dirs = new Set(files.map(([file]) => path.dirname(file)))
await Promise.all([...dirs].map((dir) => fs.mkdir(dir, { recursive: true })))
for (let index = 0; index < files.length; index += 512)
await Promise.all(files.slice(index, index + 512).map(([file, content]) => Bun.write(file, content)))
}
async function generate(fixture: Fixture) {
const dir = path.join(root, "fixtures", fixture.name)
if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir
await fs.rm(dir, { recursive: true, force: true })
console.error(`generating fixture ${fixture.name} (${fixture.tracked} tracked, ${fixture.ignored} ignored)`)
const tracked = Array.from({ length: fixture.tracked }, (_, index) => {
const file = path.join(dir, "src", `d${index % fixture.dirs}`, `f${index}.ts`)
return [file, `export const value${index} = ${index}\n`.repeat(8)] as [string, string]
})
const ignored = Array.from({ length: fixture.ignored }, (_, index) => {
const file = path.join(dir, "node_modules", `pkg${index % 500}`, `f${index}.js`)
return [file, `module.exports = ${index}\n`] as [string, string]
})
await writeMany([...tracked, ...ignored, [path.join(dir, ".gitignore"), "node_modules\ndist\n"]])
await gitInit(dir)
await Bun.write(path.join(dir, ".bench-ready"), "")
return dir
}
async function cloneOpencode() {
const dir = path.join(root, "fixtures", "opencode")
if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir
await fs.rm(dir, { recursive: true, force: true })
const source = (await $`git rev-parse --show-toplevel`.cwd(import.meta.dir).text()).trim()
console.error(`cloning ${source} into fixture opencode`)
await $`git clone --quiet --local --no-hardlinks ${source} ${dir}`.quiet()
// A realistic ignored dependency tree without paying for a full install.
await writeMany(
Array.from({ length: 30_000 }, (_, index) => [
path.join(dir, "node_modules", `pkg${index % 700}`, `f${index}.js`),
`module.exports = ${index}\n`,
]),
)
await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n")
await Bun.write(path.join(dir, ".bench-ready"), "")
return dir
}
async function gitInit(dir: string) {
await $`git init -q`.cwd(dir).quiet()
await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n")
await $`git -c core.fsmonitor=false add -A`.cwd(dir).quiet()
await $`git -c user.email=bench@opencode.test -c user.name=Bench commit -q --no-gpg-sign -m initial`.cwd(dir).quiet()
}
async function reset(dir: string) {
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(dir).quiet()
await $`git -c core.fsmonitor=false clean -qfd`.cwd(dir).quiet()
}
// ---------- harness ----------
let spawns = 0
const countingProcess = Layer.effect(
AppProcess.Service,
Effect.gen(function* () {
const real = yield* AppProcess.Service
return AppProcess.Service.of({
...real,
run: (command, options) => {
spawns++
return real.run(command, options)
},
})
}),
).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node)))
function snapshotLayer(data: string, directory: string) {
return AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
AppProcess.node.replace(countingProcess),
])
}
type Sample = { ms: number; spawns: number }
const results: Array<{ fixture: string; scenario: string; samples: Sample[] }> = []
const time = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const before = spawns
const start = performance.now()
const value = yield* effect
return { value, sample: { ms: performance.now() - start, spawns: spawns - before } }
})
function record(fixture: string, scenario: string, samples: Sample[]) {
results.push({ fixture, scenario, samples })
const sorted = samples.map((sample) => sample.ms).toSorted((a, b) => a - b)
const p = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)] ?? 0
const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length
const spawned = samples.reduce((total, sample) => total + sample.spawns, 0) / samples.length
console.log(
`${fixture.padEnd(9)} ${scenario.padEnd(22)} p50 ${p(0.5).toFixed(1).padStart(8)} ms mean ${mean
.toFixed(1)
.padStart(8)} ms max ${(sorted.at(-1) ?? 0).toFixed(1).padStart(8)} ms git ${spawned.toFixed(1).padStart(5)}`,
)
}
const edit = (dir: string, files: string[], tag: string) =>
Effect.promise(() => Promise.all(files.map((file) => Bun.write(path.join(dir, file), `// ${tag}\n${file}\n`))))
function trackedSample(dir: string, count: number) {
return $`git ls-files -z`
.cwd(dir)
.text()
.then((text) => {
const files = text.split("\0").filter((file) => file.endsWith(".ts") || file.endsWith(".md"))
const stride = Math.max(1, Math.floor(files.length / count))
return Array.from({ length: count }, (_, index) => files[(index * stride) % files.length]!)
})
}
const repeat = <A, E, R>(count: number, effect: (index: number) => Effect.Effect<Sample, E, R>) =>
Effect.forEach(
Array.from({ length: count }, (_, index) => index),
effect,
)
async function bench(name: string, dir: string) {
await reset(dir)
const data = await fs.mkdtemp(path.join(root, "data-"))
const sample = await trackedSample(dir, 100)
const program = Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const capture = snapshot
.capture()
.pipe(Effect.flatMap((id) => (id ? Effect.succeed(id) : Effect.die(new Error("capture returned undefined")))))
const cold = yield* time(capture)
record(name, "capture cold", [cold.sample])
record(
name,
"capture clean",
yield* repeat(iterations, () => time(capture).pipe(Effect.map((result) => result.sample))),
)
record(
name,
"capture edit 1",
yield* repeat(iterations, (index) =>
edit(dir, [sample[0]!], `edit-${index}`).pipe(
Effect.andThen(time(capture)),
Effect.map((result) => result.sample),
),
),
)
record(
name,
"capture add 1",
yield* repeat(iterations, (index) =>
edit(dir, [`src/new-${index}.ts`], "new").pipe(
Effect.andThen(time(capture)),
Effect.map((result) => result.sample),
),
),
)
const before = yield* capture
const edited = yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) =>
edit(dir, sample, `bulk-${index}`).pipe(
Effect.andThen(time(capture)),
Effect.map((result) => result.sample),
),
)
record(name, "capture edit 100", edited)
const after = yield* capture
record(
name,
"files (100 changed)",
yield* repeat(iterations, () =>
time(snapshot.files({ from: before, to: after })).pipe(Effect.map((result) => result.sample)),
),
)
record(
name,
"diff (100 changed)",
yield* repeat(iterations, () =>
time(snapshot.diff({ from: before, to: after })).pipe(Effect.map((result) => result.sample)),
),
)
const plan = new Map(sample.map((file) => [RelativePath.make(file), before] as const))
record(
name,
"restore 100",
yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) =>
edit(dir, sample, `restore-${index}`).pipe(
Effect.andThen(time(snapshot.restore({ files: plan }))),
Effect.map((result) => result.sample),
),
),
)
// A read-mostly agent loop under the step policy: capture at attempt start, capture at settlement,
// then list changed files. One step in five edits two files.
const steps = yield* repeat(20, (index) =>
Effect.gen(function* () {
const before = spawns
const start = performance.now()
const from = yield* capture
if (index % 5 === 0) yield* edit(dir, [sample[index]!, sample[index + 1]!], `step-${index}`)
const to = yield* capture
if (from !== to) yield* snapshot.files({ from, to })
return { ms: performance.now() - start, spawns: spawns - before }
}),
)
record(name, "step loop (per step)", steps)
}).pipe(Effect.provide(snapshotLayer(data, dir)))
await Effect.runPromise(program.pipe(Effect.provide(Logger.layer([]))))
await reset(dir)
const store = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.text()).trim()
const before = Number((await $`du -sk ${store}`.text()).split("\t")[0])
const started = performance.now()
await Effect.runPromise(
Effect.gen(function* () {
const git = yield* Git.Service
// The baseline worktree predates compaction.
if (!("objects" in git)) return
yield* git.objects.compact(
new Git.Repository({
worktree: AbsolutePath.make(dir),
gitDirectory: AbsolutePath.make(store),
commonDirectory: AbsolutePath.make(store),
}),
)
}).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))),
)
const after = Number((await $`du -sk ${store}`.text()).split("\t")[0])
console.log(
`${name.padEnd(9)} compaction ${before} KiB -> ${after} KiB in ${(performance.now() - started).toFixed(0)} ms`,
)
const size = await $`du -sk ${data}`.text()
const loose = (await $`find ${data}/snapshot -path '*/objects/??/*' -type f`.text()).split("\n").filter(Boolean)
const files = (await $`find ${data}/snapshot -maxdepth 3 -type f`.text()).split("\n").filter(Boolean)
console.log(
`${name.padEnd(9)} snapshot store ${size.split("\t")[0]} KiB, ${loose.length} loose objects, top-level files: ${files.map((file) => path.basename(file)).join(" ")}`,
)
await fs.rm(data, { recursive: true, force: true })
}
// ---------- robustness probes ----------
async function probe(label: string, run: () => Promise<boolean>) {
const ok = await run().catch((error) => {
console.error(error)
return false
})
console.log(`probe ${label.padEnd(44)} ${ok ? "PASS" : "FAIL"}`)
results.push({ fixture: "probe", scenario: label, samples: [{ ms: ok ? 1 : 0, spawns: 0 }] })
}
async function withRepo<A>(setup: (dir: string) => Promise<void>, body: (dir: string, data: string) => Promise<A>) {
const dir = await fs.realpath(await fs.mkdtemp(path.join(root, "probe-")))
const project = path.join(dir, "project")
await fs.mkdir(project)
await setup(project)
await gitInit(project)
const result = await body(project, path.join(dir, "data"))
await fs.rm(dir, { recursive: true, force: true })
return result
}
const captureOnce = (data: string, directory: string) =>
Effect.runPromise(
Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
return yield* snapshot.capture()
}).pipe(Effect.provide(snapshotLayer(data, directory)), Effect.provide(Logger.layer([]))),
)
async function snapshotGitDir(data: string) {
const projects = await fs.readdir(path.join(data, "snapshot"))
const project = path.join(data, "snapshot", projects[0]!)
return path.join(project, (await fs.readdir(project))[0]!)
}
async function probes() {
await probe("capture self-heals a zeroed index", () =>
withRepo(
(dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}),
async (dir, data) => {
await captureOnce(data, dir)
const gitDir = await snapshotGitDir(data)
await Bun.write(path.join(gitDir, "index"), new Uint8Array(1024))
await Bun.write(path.join(dir, "a.txt"), "b\n")
return (await captureOnce(data, dir)) !== undefined
},
),
)
await probe("capture survives a stale index.lock", () =>
withRepo(
(dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}),
async (dir, data) => {
await captureOnce(data, dir)
const gitDir = await snapshotGitDir(data)
await Bun.write(path.join(gitDir, "index.lock"), "")
await Bun.write(path.join(dir, "a.txt"), "b\n")
return (await captureOnce(data, dir)) !== undefined
},
),
)
await probe("capture works in a `..scope` directory", () =>
withRepo(
(dir) => Bun.write(path.join(dir, "..scope", "a.txt"), "a\n").then(() => {}),
async (dir, data) => (await captureOnce(data, path.join(dir, "..scope"))) !== undefined,
),
)
await probe("two processes capture concurrently (4x25)", () =>
withRepo(
(dir) => writeMany(Array.from({ length: 2000 }, (_, i) => [path.join(dir, `d${i % 20}`, `f${i}.txt`), `${i}\n`])),
async (dir, data) => {
await captureOnce(data, dir)
const worker = path.join(import.meta.dir, "benchmark-snapshot-worker.ts")
const outputs = await Promise.all(
Array.from({ length: 4 }, (_, id) => $`bun run ${worker} ${data} ${dir} ${id} 25`.nothrow().quiet()),
)
const failures = outputs.map((output) => Number(output.stdout.toString().trim() || "25"))
console.log(` concurrent capture failures per process: ${failures.join(", ")}`)
return failures.every((count) => count === 0)
},
),
)
}
await fs.mkdir(root, { recursive: true })
for (const name of selected) {
if (name === "probes") continue
const dir = name === "opencode" ? await cloneOpencode() : await generate(fixtures[name]!)
await bench(name, dir)
}
if (selected.includes("probes") || !flag("fixtures")) await probes()
if (jsonOut) await Bun.write(jsonOut, JSON.stringify(results, null, 2))
-1
View File
@@ -26,7 +26,6 @@ const result = await Bun.build({
"#fff",
"#photon-wasm",
"#shell-parser-wasm",
"#process-lock-ffi",
"#v1-migration",
],
splitting: true,
@@ -0,0 +1,22 @@
// Compare two snapshot-parity.ts outputs and print every diverging step per scenario.
const [left, right] = await Promise.all(process.argv.slice(2, 4).map((file) => Bun.file(file!).json()))
let divergent = 0
for (const name of new Set([...Object.keys(left), ...Object.keys(right)])) {
const a: unknown[] = left[name] ?? []
const b: unknown[] = right[name] ?? []
const differences = Array.from({ length: Math.max(a.length, b.length) }, (_, index) => index).filter(
(index) => JSON.stringify(a[index]) !== JSON.stringify(b[index]),
)
if (!differences.length) {
console.log(`same ${name}`)
continue
}
divergent++
console.log(`DIFFERENT ${name}`)
for (const index of differences) {
console.log(` step ${index}`)
console.log(` base: ${JSON.stringify(a[index])}`)
console.log(` new: ${JSON.stringify(b[index])}`)
}
}
console.log(divergent ? `${divergent} divergent scenarios` : "all scenarios identical")
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Run the snapshot parity corpus from two worktrees under every Git config variant and compare.
# script/snapshot-parity-run.sh <baseline-worktree> [variant...]
set -e
base="$1"
shift
here="$(cd "$(dirname "$0")/.." && pwd)"
out="${SNAPSHOT_BENCH_ROOT:-${TMPDIR:-/tmp}}/opencode-snapshot-parity-results"
mkdir -p "$out"
cp "$here/script/snapshot-parity.ts" "$base/packages/core/script/snapshot-parity.ts"
variants="${*:-user empty split-index skip-hash autocrlf-and-no-untracked-cache fsmonitor template-hook}"
status=0
for name in $variants; do
variant="$(echo "$name" | tr '-' ' ')"
(cd "$base/packages/core" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-base.json" >/dev/null 2>&1)
(cd "$here" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-new.json" >/dev/null 2>&1)
echo "=== $variant"
bun run "$here/script/snapshot-parity-compare.ts" "$out/$name-base.json" "$out/$name-new.json" | grep -v "^same" || status=1
done
exit $status
+706
View File
@@ -0,0 +1,706 @@
/**
* Differential corpus for the snapshot engine. Run the same file from two worktrees
* and compare the JSON: tree IDs, changed-file lists, diffs, and post-restore
* worktree digests must be byte-identical.
*
* bun run script/snapshot-parity.ts <result.json> [scenario...]
*/
import { $ } from "bun"
import crypto from "crypto"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Layer, Logger, ManagedRuntime } from "effect"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Location } from "../src/location"
import { AbsolutePath, RelativePath } from "../src/schema"
import { Snapshot } from "../src/snapshot"
import { Global } from "@opencode/util/global"
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-parity")
// User-level Git configuration reaches every snapshot command, so the corpus runs under several.
const variants: Record<string, string | undefined> = {
user: undefined,
empty: "",
"split index": "[core]\n\tsplitIndex = true\n",
"skip hash": "[index]\n\tskipHash = true\n[feature]\n\tmanyFiles = true\n",
"autocrlf and no untracked cache": "[core]\n\tautocrlf = true\n\tuntrackedCache = false\n",
fsmonitor: "[core]\n\tfsmonitor = true\n",
"template hook": "[init]\n\ttemplateDir = TEMPLATE\n",
}
const variant = process.env.PARITY_CONFIG ?? "user"
if (variants[variant] !== undefined) {
await fs.mkdir(root, { recursive: true })
const template = path.join(root, `template-${process.pid}`)
await fs.mkdir(path.join(template, "hooks"), { recursive: true })
await fs.writeFile(path.join(template, "hooks", "post-checkout"), '#!/bin/sh\necho "checkout $3" > .hooklog\n', {
mode: 0o755,
})
// Stores are created with `git init`, so template ignore rules must keep applying to them.
await fs.mkdir(path.join(template, "info"), { recursive: true })
await fs.writeFile(path.join(template, "info", "exclude"), "template-ignored/\n")
const config = path.join(root, `gitconfig-${process.pid}`)
await fs.writeFile(config, variants[variant]!.replace("TEMPLATE", template))
process.env.GIT_CONFIG_GLOBAL = config
}
const env = {
...process.env,
GIT_ALLOW_PROTOCOL: "file",
GIT_AUTHOR_NAME: "Parity",
GIT_AUTHOR_EMAIL: "parity@opencode.test",
GIT_COMMITTER_NAME: "Parity",
GIT_COMMITTER_EMAIL: "parity@opencode.test",
GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
}
type Context = {
readonly dir: string
readonly write: (file: string, content: string | Uint8Array) => Promise<void>
readonly remove: (file: string) => Promise<void>
readonly git: (args: string[], cwd?: string) => Promise<string>
readonly capture: (label: string, location?: string) => Promise<void>
readonly files: (from: string, to: string) => Promise<void>
readonly diff: (from: string, to: string, paths?: string[]) => Promise<void>
readonly restore: (label: string, files: Record<string, string>) => Promise<void>
readonly digest: (label: string) => Promise<void>
}
type Scenario = (ctx: Context) => Promise<void>
const commit = async (ctx: Context, message = "initial") => {
await ctx.git(["add", "-A"])
await ctx.git(["commit", "-q", "--no-gpg-sign", "--allow-empty", "-m", message])
}
const scenarios: Record<string, Scenario> = {
async basic(ctx) {
await ctx.write("a.txt", "a\n")
await ctx.write("b.txt", "b\n")
await ctx.write("dir/c.txt", "c\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("a.txt", "a2\n")
await ctx.remove("b.txt")
await ctx.write("dir/new.txt", "new\n")
await ctx.capture("t1")
await ctx.capture("t1-again")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.diff("t0", "t1", ["a.txt"])
await ctx.restore("undo", { "a.txt": "t0", "b.txt": "t0", "dir/new.txt": "t0" })
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "rename directory"(ctx) {
for (let i = 0; i < 20; i++) await ctx.write(`src/f${i}.ts`, `${i}\n`)
await commit(ctx)
await ctx.capture("t0")
await fs.rename(path.join(ctx.dir, "src"), path.join(ctx.dir, "lib"))
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
},
async "file becomes directory"(ctx) {
await ctx.write("a", "file\n")
await ctx.write("keep.txt", "keep\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.remove("a")
await ctx.write("a/b", "nested\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.restore("undo", { a: "t0", "a/b": "t0" })
await ctx.capture("t2")
},
async "directory becomes file"(ctx) {
await ctx.write("d/x", "x\n")
await ctx.write("d/y", "y\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.remove("d")
await ctx.write("d", "file now\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.restore("undo", { "d/x": "t0", "d/y": "t0", d: "t0" })
await ctx.capture("t2")
},
async "embedded repository"(ctx) {
await ctx.write("top.txt", "top\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("sub/inner.txt", "inner\n")
await ctx.git(["init", "-q"], path.join(ctx.dir, "sub"))
await ctx.git(["add", "-A"], path.join(ctx.dir, "sub"))
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "inner"], path.join(ctx.dir, "sub"))
await ctx.capture("t1")
await ctx.write("sub/inner.txt", "changed\n")
await ctx.capture("t2")
await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "inner 2"], path.join(ctx.dir, "sub"))
await ctx.capture("t3")
await ctx.files("t0", "t3")
await ctx.diff("t0", "t3")
},
async symlinks(ctx) {
await ctx.write("target.txt", "target\n")
await ctx.write("dir/inside.txt", "inside\n")
await ctx.write("plain.txt", "plain\n")
await commit(ctx)
await ctx.capture("t0")
await fs.symlink("target.txt", path.join(ctx.dir, "link"))
await fs.symlink("dir", path.join(ctx.dir, "dirlink"))
await fs.symlink("missing", path.join(ctx.dir, "dangling"))
await ctx.remove("plain.txt")
await fs.symlink("target.txt", path.join(ctx.dir, "plain.txt"))
await ctx.capture("t1")
await ctx.write("target.txt", "target 2\n")
await ctx.capture("t2")
await ctx.files("t0", "t2")
await ctx.diff("t0", "t2")
await ctx.restore("undo", { link: "t0", dirlink: "t0", dangling: "t0", "plain.txt": "t0", "target.txt": "t0" })
await ctx.capture("t3")
},
async "executable bit"(ctx) {
await ctx.write("run.sh", "echo hi\n")
await commit(ctx)
await ctx.capture("t0")
await fs.chmod(path.join(ctx.dir, "run.sh"), 0o755)
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.restore("undo", { "run.sh": "t0" })
await ctx.capture("t2")
},
async "special names"(ctx) {
const names = [
"with space.txt",
"caf\u00e9.txt",
"cafe\u0301-nfd.txt",
'quote"s.txt',
"-rf",
":colon.txt",
"!bang.txt",
"#hash.txt",
"app/[slug]/page.tsx",
"star*.txt",
"q?.txt",
"back\\slash.txt",
"tab\tname.txt",
"new\nline.txt",
"\u65e5\u672c\u8a9e/\u30d5\u30a1\u30a4\u30eb.txt",
]
for (const name of names) await ctx.write(name, `${name}\n`)
await commit(ctx)
await ctx.capture("t0")
for (const name of names) await ctx.write(name, `${name} changed\n`)
await ctx.write("added [x].txt", "added\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.restore("undo", Object.fromEntries([...names, "added [x].txt"].map((name) => [name, "t0"])))
await ctx.capture("t2")
},
async "ignore rules"(ctx) {
await ctx.write(".gitignore", "*.log\nbuild/\n!keep.log\n")
await ctx.write("keep.log", "keep\n")
await ctx.write("src/a.ts", "a\n")
await ctx.write("build/forced.txt", "forced\n")
await ctx.git(["add", "-A"])
await ctx.git(["add", "-f", "build/forced.txt"])
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "initial"])
await ctx.write(".git/info/exclude", "secret/\n*.local\n")
await ctx.capture("t0")
await ctx.write("debug.log", "ignored\n")
await ctx.write("keep.log", "keep 2\n")
await ctx.write("build/forced.txt", "forced 2\n")
await ctx.write("build/out.js", "out\n")
await ctx.write("secret/key.txt", "key\n")
await ctx.write("config.local", "local\n")
await ctx.write("template-ignored/x.txt", "template\n")
await ctx.write("src/a.ts", "a2\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.write(".git/info/exclude", "")
await ctx.capture("t2")
await ctx.files("t1", "t2")
},
async "gitignore changes"(ctx) {
await ctx.write("gen/out.txt", "1\n")
await ctx.write("src.txt", "src\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write(".gitignore", "gen/\n")
await ctx.write("gen/out.txt", "2\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.write(".gitignore", "")
await ctx.capture("t2")
await ctx.files("t1", "t2")
},
async "large files"(ctx) {
await ctx.write("tracked.bin", "small\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("big.bin", new Uint8Array(3 * 1024 * 1024).fill(7))
await ctx.write("tracked.bin", new Uint8Array(3 * 1024 * 1024).fill(9))
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.write("big.bin", "now small\n")
await ctx.capture("t2")
await ctx.files("t1", "t2")
},
async "line endings"(ctx) {
await ctx.write(".gitattributes", "* text=auto\n*.bat eol=crlf\n")
await ctx.write("unix.txt", "one\ntwo\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("unix.txt", "one\r\ntwo\r\nthree\r\n")
await ctx.write("run.bat", "echo\r\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.restore("undo", { "unix.txt": "t0", "run.bat": "t0" })
await ctx.digest("after-undo")
},
async "merge conflict in source"(ctx) {
await ctx.write("c.txt", "base\n")
await commit(ctx)
await ctx.git(["checkout", "-q", "-b", "other"])
await ctx.write("c.txt", "other\n")
await commit(ctx, "other")
await ctx.git(["checkout", "-q", "-"])
await ctx.write("c.txt", "main\n")
await commit(ctx, "main")
await ctx.git(["merge", "-q", "other"]).catch(() => "")
await ctx.capture("t0")
await ctx.write("c.txt", "resolved\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
},
async "case-only rename"(ctx) {
await ctx.write("Readme.md", "readme\n")
await commit(ctx)
await ctx.capture("t0")
await fs.rename(path.join(ctx.dir, "Readme.md"), path.join(ctx.dir, "README.md"))
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.write("README.md", "changed\n")
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "location subdirectory"(ctx) {
await ctx.write("pkg/a.txt", "a\n")
await ctx.write("other/b.txt", "b\n")
await commit(ctx)
await ctx.capture("t0", "pkg")
await ctx.write("pkg/a.txt", "a2\n")
await ctx.write("pkg/new.txt", "new\n")
await ctx.write("other/b.txt", "b2\n")
await ctx.capture("t1", "pkg")
await ctx.files("t0", "t1")
await ctx.capture("t2", ".")
await ctx.files("t1", "t2")
await ctx.capture("t3", "pkg")
await ctx.files("t2", "t3")
},
async "many untracked"(ctx) {
await ctx.write("seed.txt", "seed\n")
await commit(ctx)
for (let i = 0; i < 3000; i++) await ctx.write(`gen/d${i % 30}/f${i}.txt`, `${i}\n`)
await ctx.capture("t0")
await ctx.capture("t0-again")
},
async "empty directories and delete all"(ctx) {
await ctx.write("a/b/c.txt", "c\n")
await ctx.write("d.txt", "d\n")
await commit(ctx)
await ctx.capture("t0")
await fs.mkdir(path.join(ctx.dir, "empty/nested"), { recursive: true })
await ctx.capture("t1")
await ctx.remove("a")
await ctx.remove("d.txt")
await ctx.capture("t2")
await ctx.files("t0", "t2")
await ctx.restore("undo", { "a/b/c.txt": "t0", "d.txt": "t0" })
await ctx.capture("t3")
},
async "restore with overlapping paths"(ctx) {
await ctx.write("a", "file a\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.remove("a")
await ctx.write("a/b", "nested b\n")
await ctx.capture("t1")
await ctx.remove("a")
await ctx.write("a", "file again\n")
await ctx.capture("t2")
await ctx.restore("order-1", { a: "t0", "a/b": "t0" })
await ctx.restore("order-2", { "a/b": "t1", a: "t1" })
await ctx.restore("order-3", { a: "t1", "a/b": "t1" })
await ctx.restore("order-4", { "a/b": "t0", a: "t2" })
},
async "restore from several trees"(ctx) {
await ctx.write("x.txt", "x0\n")
await ctx.write("y.txt", "y0\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("x.txt", "x1\n")
await ctx.write("z.txt", "z1\n")
await ctx.capture("t1")
await ctx.write("x.txt", "x2\n")
await ctx.write("y.txt", "y2\n")
await ctx.write("z.txt", "z2\n")
await ctx.write("w.txt", "w2\n")
await ctx.restore("mixed", { "x.txt": "t1", "y.txt": "t0", "z.txt": "t0", "w.txt": "t1" })
},
async "staged changes in source"(ctx) {
await ctx.write("a.txt", "a\n")
await commit(ctx)
await ctx.write("a.txt", "staged\n")
await ctx.write("new.txt", "staged new\n")
await ctx.git(["add", "-A"])
await ctx.write("a.txt", "worktree\n")
await ctx.capture("t0")
await ctx.write("a.txt", "worktree 2\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
},
async "binary and replaced types"(ctx) {
await ctx.write("img.bin", new Uint8Array([0, 1, 2, 3, 0, 255]))
await ctx.write("f.txt", "file\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("img.bin", new Uint8Array([0, 9, 9, 3, 0, 255, 1]))
await ctx.remove("f.txt")
await fs.symlink("img.bin", path.join(ctx.dir, "f.txt"))
await ctx.capture("t1")
await ctx.files("t0", "t1")
await ctx.diff("t0", "t1")
await ctx.remove("f.txt")
await ctx.write("f.txt", "file\n")
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "sparse checkout source"(ctx) {
await ctx.write("in/a.txt", "a\n")
await ctx.write("out/b.txt", "b\n")
await commit(ctx)
await ctx.git(["sparse-checkout", "set", "in"])
await ctx.capture("t0")
await ctx.write("in/a.txt", "a2\n")
await ctx.write("in/new.txt", "new\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
},
async "unreadable file"(ctx) {
await ctx.write("ok.txt", "ok\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("locked.txt", "locked\n")
await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o000)
await ctx.capture("t1")
await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o644)
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "no commits yet"(ctx) {
await ctx.write("a.txt", "a\n")
await ctx.capture("t0")
await ctx.git(["add", "a.txt"])
await ctx.write("b.txt", "b\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
},
async "special files"(ctx) {
await ctx.write("a.txt", "a\n")
await commit(ctx)
await ctx.capture("t0")
await $`mkfifo ${path.join(ctx.dir, "pipe")}`.quiet()
await ctx.capture("t1")
await ctx.remove("pipe")
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "tracked submodule"(ctx) {
const other = path.join(path.dirname(ctx.dir), "library")
await fs.mkdir(other)
await ctx.git(["init", "-q"], other)
await fs.writeFile(path.join(other, "lib.txt"), "lib\n")
await ctx.git(["add", "-A"], other)
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "lib"], other)
await ctx.git(["-c", "protocol.file.allow=always", "submodule", "add", "-q", other, "vendor/library"])
// The absolute temporary URL differs between runs; pin it so trees are comparable.
await ctx.git(["config", "-f", ".gitmodules", "submodule.vendor/library.url", "../library"])
await commit(ctx)
await ctx.capture("t0")
await ctx.write("vendor/library/lib.txt", "changed\n")
await ctx.capture("t1")
await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "bump"], path.join(ctx.dir, "vendor/library"))
await ctx.capture("t2")
await ctx.files("t0", "t2")
await ctx.diff("t0", "t2")
},
async "index flags in source"(ctx) {
await ctx.write("assumed.txt", "a\n")
await ctx.write("skipped.txt", "s\n")
await ctx.write("plain.txt", "p\n")
await commit(ctx)
await ctx.git(["update-index", "--assume-unchanged", "assumed.txt"])
await ctx.git(["update-index", "--skip-worktree", "skipped.txt"])
await ctx.write("intent.txt", "intent\n")
await ctx.git(["add", "-N", "intent.txt"])
await ctx.capture("t0")
await ctx.write("assumed.txt", "a2\n")
await ctx.write("skipped.txt", "s2\n")
await ctx.write("intent.txt", "intent 2\n")
await ctx.write("plain.txt", "p2\n")
await ctx.capture("t1")
await ctx.files("t0", "t1")
},
async "restore key spellings"(ctx) {
await ctx.write("Readme.md", "readme\n")
await ctx.write("dir/a.txt", "a\n")
await ctx.write("dir/b.txt", "b\n")
await ctx.write("caf\u00e9.txt", "nfc\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.write("Readme.md", "changed\n")
await ctx.write("dir/a.txt", "a2\n")
await ctx.write("dir/c.txt", "c2\n")
await ctx.write("caf\u00e9.txt", "nfc 2\n")
await ctx.restore("case", { "readme.md": "t0" })
await ctx.restore("directory", { dir: "t0" })
await ctx.restore("nfd", { "cafe\u0301.txt": "t0" })
await ctx.restore("dot-slash", { "./Readme.md": "t0" })
},
async "large batched restore"(ctx) {
for (let i = 0; i < 300; i++) await ctx.write(`src/m${i % 7}/f${i}.ts`, `export const v = ${i}\n`)
await commit(ctx)
await ctx.capture("t0")
for (let i = 0; i < 300; i += 2) await ctx.write(`src/m${i % 7}/f${i}.ts`, `changed ${i}\n`)
for (let i = 0; i < 50; i++) await ctx.write(`src/new/n${i}.ts`, `new ${i}\n`)
for (let i = 1; i < 300; i += 3) await ctx.remove(`src/m${i % 7}/f${i}.ts`)
await ctx.capture("t1")
await ctx.files("t0", "t1")
const changed = await Promise.resolve().then(async () => {
const files: Record<string, string> = {}
for (let i = 0; i < 300; i++) files[`src/m${i % 7}/f${i}.ts`] = "t0"
for (let i = 0; i < 50; i++) files[`src/new/n${i}.ts`] = "t0"
return files
})
await ctx.restore("all", changed)
await ctx.capture("t2")
await ctx.files("t0", "t2")
},
async "restore blocked by a file"(ctx) {
await ctx.write("a/b.txt", "b\n")
await ctx.write("z.txt", "z\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.remove("a")
await ctx.write("a", "now a file\n")
await ctx.write("z.txt", "z2\n")
await ctx.restore("blocked", { "a/b.txt": "t0", "z.txt": "t0" })
},
async "location with pattern characters"(ctx) {
await ctx.write("app/[id]/page.tsx", "page\n")
await ctx.write("app/i/other.tsx", "other\n")
await commit(ctx)
await ctx.capture("t0", "app/[id]")
await ctx.write("app/[id]/page.tsx", "page 2\n")
await ctx.write("app/i/other.tsx", "other 2\n")
await ctx.capture("t1", "app/[id]")
await ctx.files("t0", "t1")
},
async "restore paths absent from both sides"(ctx) {
await ctx.write("a.txt", "a\n")
await commit(ctx)
await ctx.capture("t0")
await ctx.restore("missing", { "never.txt": "t0", "gone/deep/x.txt": "t0" })
await ctx.write("later.txt", "later\n")
await ctx.restore("remove-later", { "later.txt": "t0" })
},
}
async function run(name: string, scenario: Scenario) {
const base = await fs.realpath(await fs.mkdtemp(path.join(root, "case-")))
const dir = path.join(base, "project")
const data = path.join(base, "data")
await fs.mkdir(dir, { recursive: true })
const git = (args: string[], cwd = dir) =>
$`git -c core.fsmonitor=false -c core.splitIndex=false -c init.defaultBranch=main ${args}`.cwd(cwd).env(env).quiet().text()
await git(["init", "-q"])
const log: unknown[] = []
const trees = new Map<string, Snapshot.ID | undefined>()
// One long-lived runtime per Location, as in production, so in-process caches are exercised.
const runtimes = new Map<string, ManagedRuntime.ManagedRuntime<Snapshot.Service, never>>()
const runtime = (location = ".") => {
const existing = runtimes.get(location)
if (existing) return existing
const created = ManagedRuntime.make(
Layer.provide(snapshotLayer(data, path.join(dir, location)), Logger.layer([])) as Layer.Layer<Snapshot.Service>,
)
runtimes.set(location, created)
return created
}
const use = <A>(location: string | undefined, body: (snapshot: Snapshot.Interface) => Effect.Effect<A, unknown>) =>
runtime(location)
.runPromise(
Effect.gen(function* () {
return yield* body(yield* Snapshot.Service)
}).pipe(Effect.exit),
)
.then((exit) => (exit._tag === "Success" ? { ok: exit.value } : { error: true }))
const tree = (label: string) => {
const id = trees.get(label)
if (!id) throw new Error(`no tree ${label}`)
return id
}
const ctx: Context = {
dir,
write: async (file, content) => {
await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true })
await fs.writeFile(path.join(dir, file), content)
},
remove: (file) => fs.rm(path.join(dir, file), { recursive: true, force: true }),
git,
capture: async (label, location) => {
const result = await use(location, (snapshot) => snapshot.capture())
const id = "ok" in result ? result.ok : undefined
trees.set(label, id)
log.push({ capture: label, tree: id ?? null, entries: id ? await listing(data, id) : null })
},
files: async (from, to) => {
if (!trees.get(from) || !trees.get(to)) return void log.push({ files: [from, to], skipped: true })
log.push({ files: [from, to], result: await use(undefined, (s) => s.files({ from: tree(from), to: tree(to) })) })
},
diff: async (from, to, paths) => {
if (!trees.get(from) || !trees.get(to)) return void log.push({ diff: [from, to], skipped: true })
const result = await use(undefined, (s) =>
s.diff({ from: tree(from), to: tree(to), paths: paths?.map((file) => RelativePath.make(file)) }),
)
log.push({
diff: [from, to, paths ?? null],
result:
"ok" in result && result.ok
? result.ok.map((file) => ({
...file,
patch: crypto.createHash("sha1").update(file.patch).digest("hex"),
}))
: result,
})
},
restore: async (label, files) => {
const plan = new Map(
Object.entries(files).flatMap(([file, from]) => {
const id = trees.get(from)
return id ? [[RelativePath.make(file), id] as const] : []
}),
)
const result = await use(undefined, (s) => s.restore({ files: plan }))
log.push({ restore: label, result: "ok" in result ? "ok" : result, worktree: await digest(dir) })
},
digest: async (label) => {
log.push({ digest: label, worktree: await digest(dir) })
},
}
const failure = await scenario(ctx).then(
() => undefined,
(error: unknown) => String(error),
)
if (failure) log.push({ scenarioError: failure })
await Promise.all([...runtimes.values()].map((item) => item.dispose()))
await $`chmod -R u+rwX ${base}`.quiet().nothrow()
if (process.env.PARITY_KEEP) console.error(`kept ${base}`)
if (!process.env.PARITY_KEEP) await fs.rm(base, { recursive: true, force: true })
return log
}
function snapshotLayer(data: string, directory: string) {
return AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
])
}
/** The recursive listing of a captured tree, read from whichever snapshot store holds it. */
async function listing(data: string, tree: string) {
const stores = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.quiet().text())
.split("\n")
.filter(Boolean)
for (const store of stores) {
const result = await $`git --git-dir ${store} ls-tree -r -z ${tree}`.quiet().nothrow()
if (result.exitCode === 0) return result.stdout.toString().split("\0").filter(Boolean)
}
return null
}
/** Paths, types, modes, and content hashes of the worktree, excluding `.git`. */
async function digest(dir: string) {
const entries: string[] = []
const walk = async (current: string) => {
for (const entry of (await fs.readdir(current, { withFileTypes: true })).toSorted((a, b) =>
a.name < b.name ? -1 : 1,
)) {
const full = path.join(current, entry.name)
const relative = path.relative(dir, full)
if (relative === ".git" || relative.startsWith(".git/")) continue
const stat = await fs.lstat(full)
if (stat.isSymbolicLink()) entries.push(`L ${relative} -> ${await fs.readlink(full)}`)
else if (stat.isDirectory()) {
entries.push(`D ${relative}`)
await walk(full)
} else {
const content = await fs.readFile(full).catch(() => Buffer.from("<unreadable>"))
entries.push(
`F ${relative} ${stat.mode & 0o111 ? "x" : "-"} ${crypto.createHash("sha1").update(content).digest("hex")}`,
)
}
}
}
await walk(dir)
return entries
}
await fs.mkdir(root, { recursive: true })
const [outputFile, ...selected] = process.argv.slice(2)
const output: Record<string, unknown> = {}
for (const [name, scenario] of Object.entries(scenarios)) {
if (selected.length && !selected.includes(name)) continue
output[name] = await run(name, scenario)
}
await Bun.write(outputFile!, JSON.stringify(output, null, 2))
+2 -3
View File
@@ -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: () =>
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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) => {
-6
View File
@@ -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. */
-6
View File
@@ -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
+19 -9
View File
@@ -1,6 +1,7 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -60,15 +61,24 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
+681 -107
View File
@@ -1,13 +1,13 @@
export * as Git from "./git.js"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Cause, Context, Effect, Exit, Layer, Option, Schedule, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
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"
@@ -30,6 +30,8 @@ const snapshotConfig = `[core]
symlinks = true
fsmonitor = false
untrackedCache = true
# A split index cannot name its shared file once manyFiles skips index checksums.
splitIndex = false
[feature]
manyFiles = true
[index]
@@ -40,6 +42,22 @@ const snapshotConfig = `[core]
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
export type TreeID = typeof TreeID.Type
const privateIndexPrefix = "index.opencode-"
const excludeMarker = "# opencode: mirrored from the source repository; edits below are replaced\n"
// Like `git gc --auto`, pack once loose objects accumulate, and merge packs before lookups slow down.
const compactLooseLimit = 2048
const compactPackLimit = 16
export interface CaptureInput {
readonly repository: Repository
readonly scopes: readonly RelativePath[]
/** Source repository whose ignore rules decide which paths are recorded. */
readonly ignores?: Repository
/** Repository whose index rebuilds a corrupt snapshot index without rehashing tracked files. */
readonly seed?: Repository
readonly maximumUntrackedFileBytes?: number
}
export class OperationError extends Schema.TaggedError<OperationError>()("Git.OperationError", {
operation: Schema.Literals([
"clone",
@@ -52,6 +70,7 @@ export class OperationError extends Schema.TaggedError<OperationError>()("Git.Op
"list_files",
"diff",
"restore",
"compact",
]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
@@ -134,12 +153,7 @@ export interface Interface {
}) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
}
readonly tree: {
readonly capture: (input: {
repository: Repository
scopes: readonly RelativePath[]
ignores?: Repository
maximumUntrackedFileBytes?: number
}) => Effect.Effect<TreeID, OperationError>
readonly capture: (input: CaptureInput) => Effect.Effect<TreeID, OperationError>
readonly write: (repository: Repository) => Effect.Effect<TreeID, OperationError>
readonly files: (input: {
repository: Repository
@@ -152,12 +166,20 @@ 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>
}) => Effect.Effect<void, OperationError>
}
readonly objects: {
/**
* Pack loose objects and merge small packs without dropping any object,
* reachable or not. Returns without work below the thresholds; safe to run
* concurrently with captures and with other processes.
*/
readonly compact: (repository: Repository) => Effect.Effect<void, OperationError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
@@ -310,13 +332,13 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string | Uint8Array; env?: Record<string, string>; maxOutputBytes?: number; index?: string },
) {
const result = yield* proc
.run(
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
env: options?.index ? { ...options.env, GIT_INDEX_FILE: options.index } : options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
@@ -342,43 +364,148 @@ const layer = Layer.effect(
})
})
/**
* A new store is initialized in a private staging directory and renamed into
* place, so processes racing to create the same store never observe or write a
* half-initialized one; the loser discards its copy and adopts the winner's.
*/
const create = Effect.fn("Git.repo.create")(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
seed?: Repository
}) {
const operationError = (message: string) => (cause: unknown) =>
new OperationError({ operation: "create", directory: input.gitDirectory, message, cause })
yield* fs.ensureDir(input.gitDirectory).pipe(Effect.mapError(operationError("Failed to create Git storage")))
const repository = new Repository({
worktree: input.worktree,
gitDirectory: input.gitDirectory,
commonDirectory: input.gitDirectory,
})
yield* repositoryOperation("create", repository, ["init"])
if (yield* fs.existsSafe(path.join(input.gitDirectory, "HEAD"))) {
yield* initialize({ ...input, directory: input.gitDirectory })
return repository
}
const staging = AbsolutePath.make(
`${input.gitDirectory}.init-${process.pid}-${Math.random().toString(36).slice(2)}`,
)
yield* Effect.acquireUseRelease(
Effect.succeed(staging),
(directory) =>
Effect.gen(function* () {
yield* initialize({ ...input, directory })
const renamed = yield* fs.rename(directory, input.gitDirectory).pipe(Effect.exit)
if (Exit.isSuccess(renamed) || (yield* fs.existsSafe(path.join(input.gitDirectory, "HEAD")))) return
return yield* new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to move Git storage into place",
cause: Cause.squash(renamed.cause),
})
}),
(directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
return repository
})
const initialize = Effect.fnUntraced(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
directory: AbsolutePath
seed?: Repository
}) {
const operationError = (message: string) => (cause: unknown) =>
new OperationError({ operation: "create", directory: input.gitDirectory, message, cause })
yield* fs.ensureDir(input.directory).pipe(Effect.mapError(operationError("Failed to create Git storage")))
yield* repositoryOperation(
"create",
new Repository({ worktree: input.worktree, gitDirectory: input.directory, commonDirectory: input.directory }),
["init"],
)
yield* Effect.gen(function* () {
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
const config = path.join(input.gitDirectory, "config")
yield* fs.writeFileString(path.join(input.directory, snapshotConfigFile), snapshotConfig)
const config = path.join(input.directory, "config")
const current = yield* fs.readFileString(config)
if (current.includes(snapshotConfigInclude)) return
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
}).pipe(Effect.mapError(operationError("Failed to configure Git storage")))
if (!input.seed) return repository
if (!input.seed) return
yield* fs
.ensureDir(path.join(input.gitDirectory, "objects", "info"))
.ensureDir(path.join(input.directory, "objects", "info"))
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
yield* fs
.writeFileString(
path.join(input.gitDirectory, "objects", "info", "alternates"),
path.join(input.directory, "objects", "info", "alternates"),
path.join(input.seed.commonDirectory, "objects") + "\n",
)
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.directory, "index"))
.pipe(Effect.ignore)
return repository
})
/**
* Report modified, deleted, and non-ignored untracked paths against the index.
* Both commands only read the index, so they run against the shared index
* without taking `index.lock`. Two parallel processes beat one combined
* `ls-files -m -o`, whose lstat pass is not threaded like diff-files'.
*/
const scan = Effect.fnUntraced(function* (repository: Repository, scope: RelativePath) {
const list = (args: string[]) =>
repositoryOperation("refresh", repository, args).pipe(
// Embedded repositories are listed as `dir/`; update-index records them as gitlinks.
Effect.map((result) => nuls(result.text).map((file) => RelativePath.make(file.replace(/\/$/, "")))),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", literal(scope)]),
list(["ls-files", "--others", "--exclude-standard", "-z", "--", literal(scope)]),
],
{ concurrency: 2 },
)
return { tracked, untracked }
})
const stage = Effect.fnUntraced(function* (input: {
repository: Repository
changes: { tracked: readonly RelativePath[]; untracked: readonly RelativePath[] }
ignores?: Repository
maximumUntrackedFileBytes?: number
index?: string
}) {
const candidates = [...input.changes.tracked, ...input.changes.untracked]
if (!candidates.length) return { skipped: [] }
const excluded = input.ignores
? yield* ignored({ repository: input.ignores, paths: candidates })
: new Set<RelativePath>()
const maximum = input.maximumUntrackedFileBytes
const skipped = maximum
? (yield* Effect.forEach(
input.changes.untracked.filter((item) => !excluded.has(item)),
(item) =>
fs.stat(path.join(input.repository.worktree, item)).pipe(
Effect.map((info) => (info.type === "File" && Number(info.size) > maximum ? item : undefined)),
Effect.orElseSucceed(() => undefined),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
: []
const skip = new Set(skipped)
const staged = candidates.filter((item) => !excluded.has(item) && !skip.has(item))
const removed = [...excluded, ...skipped]
// update-index takes literal paths, so large lists avoid add's quadratic pathspec matching.
if (removed.length)
yield* repositoryOperation("refresh", input.repository, ["update-index", "--force-remove", "-z", "--stdin"], {
stdin: removed.join("\0") + "\0",
index: input.index,
})
if (staged.length)
yield* repositoryOperation(
"refresh",
input.repository,
["update-index", "--add", "--remove", "--replace", "-z", "--stdin"],
{ stdin: staged.join("\0") + "\0", index: input.index },
)
return { skipped }
})
const refresh = Effect.fn("Git.index.refresh")(function* (input: {
@@ -387,52 +514,7 @@ const layer = Layer.effect(
ignores?: Repository
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]),
],
{ concurrency: 2 },
)
const candidates = Array.from(new Set([...tracked, ...untracked])).map((file) => RelativePath.make(file))
if (!candidates.length) return { skipped: [] }
const excluded = input.ignores
? yield* ignored({ repository: input.ignores, paths: candidates })
: new Set<RelativePath>()
const allowed = candidates.filter((item) => !excluded.has(item))
const maximum = input.maximumUntrackedFileBytes
const skipped = maximum
? (yield* Effect.forEach(
untracked.filter((item) => allowed.includes(RelativePath.make(item))),
(item) =>
fs.stat(path.join(input.repository.worktree, item)).pipe(
Effect.map((info) =>
info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
),
Effect.orElseSucceed(() => undefined),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
: []
const stage = allowed.filter((item) => !skipped.includes(item))
const remove = [...excluded, ...skipped]
if (remove.length)
yield* repositoryOperation(
"refresh",
input.repository,
["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"],
{ stdin: remove.join("\0") + "\0" },
)
if (stage.length)
yield* repositoryOperation(
"refresh",
input.repository,
["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"],
{ stdin: stage.join("\0") + "\0" },
)
return { skipped }
return yield* stage({ ...input, changes: yield* scan(input.repository, input.scope) })
})
const ignored = Effect.fn("Git.index.ignored")(function* (input: {
@@ -472,26 +554,240 @@ const layer = Layer.effect(
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository, index?: string) {
const tree = (yield* repositoryOperation("write_tree", repository, ["write-tree"], { index })).text.trim()
if (/^[0-9a-f]{40,64}$/.test(tree)) return TreeID.make(tree)
return yield* new OperationError({
operation: "write_tree",
directory: repository.worktree,
message: `Invalid tree ID: ${tree}`,
})
})
const captureTree = Effect.fn("Git.tree.capture")(
(input: {
repository: Repository
scopes: readonly RelativePath[]
ignores?: Repository
maximumUntrackedFileBytes?: number
}) =>
locked(
input.repository,
Effect.gen(function* () {
yield* Effect.forEach(input.scopes, (scope) => refresh({ ...input, scope }), { discard: true })
return yield* writeTree(input.repository)
}),
const sharedIndex = (repository: Repository) => path.join(repository.gitDirectory, "index")
/** Identity of an index file; Git only replaces an index by renaming a new file into place. */
const stamp = (file: string) =>
fs.stat(file).pipe(
Effect.map((info) =>
[Option.getOrUndefined(info.ino), info.size, Option.getOrUndefined(info.mtime)?.getTime()].join(":"),
),
Effect.orElseSucceed(() => undefined),
)
/**
* Run index writes against a private index, then publish it with an atomic
* rename. Processes never contend on `index.lock`, an interrupted or killed
* writer cannot leave a partial shared index, and a stale lock is irrelevant.
* The private index starts as a hard link: Git never writes an index in place,
* it writes a lock file and renames it over the private name, so the shared
* file is never modified and no bytes are copied. The shared index is only a
* stat cache over the object store; when writers race, the last publish wins
* and the next capture reconciles against the worktree.
*/
const privateIndex = <A, E, R>(repository: Repository, use: (index: string) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.gen(function* () {
const shared = sharedIndex(repository)
const index = path.join(
repository.gitDirectory,
`${privateIndexPrefix}${process.pid}-${Math.random().toString(36).slice(2)}`,
)
if (!(yield* fs.existsSafe(shared))) return index
const linked = yield* fs.link(shared, index).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (linked) return index
const info = yield* fs.stat(shared).pipe(Effect.option)
yield* fs.copyFile(shared, index).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "refresh",
directory: repository.gitDirectory,
message: "Failed to prepare a private index",
cause,
}),
),
)
// A copy must keep the timestamp Git uses to detect racily clean entries.
const mtime = Option.isSome(info) ? Option.getOrUndefined(info.value.mtime) : undefined
if (mtime && Option.isSome(info))
yield* fs
.utimes(
index,
Option.getOrElse(info.value.atime, () => mtime),
mtime,
)
.pipe(Effect.ignore)
return index
}),
(index) =>
Effect.gen(function* () {
const value = yield* use(index)
const published = yield* Effect.uninterruptible(
Effect.gen(function* () {
// Stamp the private file before it takes the shared name, so a concurrent publish can never
// pair another writer's index with this tree.
const identity = yield* stamp(index)
const renamed = yield* fs.rename(index, sharedIndex(repository)).pipe(
// Windows reports a transient sharing violation while another process reads the index.
Effect.retry({ times: 3, schedule: Schedule.spaced("20 millis") }),
Effect.as(true),
Effect.orElseSucceed(() => false),
)
return renamed ? identity : undefined
}),
)
return { value, published }
}),
(index) => fs.remove(index, { force: true }).pipe(Effect.ignore),
)
// Clean captures return the tree last written from the exact shared index they scanned.
const trees = new Map<string, { readonly stamp: string; readonly tree: TreeID }>()
const swept = new Set<string>()
const captureOnce = Effect.fnUntraced(function* (input: CaptureInput) {
yield* syncExcludes(input.repository, input.ignores)
const changes = yield* Effect.forEach(input.scopes, (scope) => scan(input.repository, scope), {
concurrency: "unbounded",
})
const current = yield* stamp(sharedIndex(input.repository))
const cached = trees.get(input.repository.gitDirectory)
if (
current &&
cached?.stamp === current &&
changes.every((change) => !change.tracked.length && !change.untracked.length)
)
return cached.tree
const result = yield* privateIndex(input.repository, (index) =>
Effect.gen(function* () {
yield* Effect.forEach(changes, (change) => stage({ ...input, changes: change, index }), { discard: true })
return yield* writeTree(input.repository, index)
}),
)
if (result.published) trees.set(input.repository.gitDirectory, { stamp: result.published, tree: result.value })
if (!result.published) trees.delete(input.repository.gitDirectory)
return result.value
})
const captureTree = Effect.fn("Git.tree.capture")((input: CaptureInput) =>
locked(
input.repository,
Effect.gen(function* () {
if (!swept.has(input.repository.gitDirectory)) {
swept.add(input.repository.gitDirectory)
yield* sweepPrivateIndexes(input.repository)
yield* upgradeConfig(input.repository)
}
return yield* captureOnce(input).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (!(yield* indexCorrupt(input.repository))) return yield* error
yield* Effect.logWarning("rebuilding corrupt snapshot index", {
directory: input.repository.gitDirectory,
message: error.message,
})
yield* reseedIndex(input.repository, input.seed)
return yield* captureOnce(input)
}),
),
)
}),
),
)
/**
* Mirror the source repository's info/exclude into a marked block of the
* store's own file, so one ls-files pass applies both, exactly like listing
* with the store's rules and then filtering with the source's. Content above
* the marker, such as patterns from an init template, is kept.
*/
const syncExcludes = Effect.fnUntraced(function* (repository: Repository, source?: Repository) {
if (!source || source.gitDirectory === repository.gitDirectory) return
const target = path.join(repository.commonDirectory, "info", "exclude")
const [wanted, current] = yield* Effect.all(
[fs.readFileStringSafe(path.join(source.commonDirectory, "info", "exclude")), fs.readFileStringSafe(target)],
{ concurrency: 2 },
).pipe(Effect.orElseSucceed(() => [undefined, undefined] as const))
const own = (current ?? "").split(excludeMarker)[0]!
const next = wanted ? `${own}${own && !own.endsWith("\n") ? "\n" : ""}${excludeMarker}${wanted}` : own
if (next === (current ?? "")) return
// Concurrent Git processes must never read a partially written file.
const staged = `${target}.${process.pid}-${Math.random().toString(36).slice(2)}`
yield* fs
.writeWithDirs(staged, next)
.pipe(
Effect.andThen(fs.rename(staged, target)),
Effect.ignore,
Effect.ensuring(fs.remove(staged, { force: true }).pipe(Effect.ignore)),
)
})
/**
* Only an index Git itself cannot read is rebuilt, decided by exit status rather
* than by localized messages. Other failures, such as an unreadable worktree
* file, surface unchanged.
*/
const indexCorrupt = Effect.fnUntraced(function* (repository: Repository) {
if (!(yield* fs.existsSafe(sharedIndex(repository)))) return false
return yield* repositoryOperation("refresh", repository, [
"ls-files",
"-z",
"--",
literal(".opencode-probe"),
]).pipe(
Effect.as(false),
Effect.orElseSucceed(() => true),
)
})
const reseedIndex = Effect.fnUntraced(function* (repository: Repository, seed?: Repository) {
yield* fs.remove(sharedIndex(repository), { force: true }).pipe(Effect.ignore)
trees.delete(repository.gitDirectory)
if (!seed) return
// Seeding keeps the source's stat cache and cache-tree, so recovery does not rehash every tracked file.
yield* privateIndex(repository, (index) =>
fs.copyFile(path.join(seed.gitDirectory, "index"), index).pipe(Effect.ignore),
)
})
// Stores created by earlier releases keep the settings they were created with; only OpenCode's own file is rewritten.
const upgradeConfig = Effect.fnUntraced(function* (repository: Repository) {
const file = path.join(repository.gitDirectory, snapshotConfigFile)
const current = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (current === undefined || current === snapshotConfig) return
// Concurrent Git processes must never read a partially written file.
const staged = `${file}.${process.pid}-${Math.random().toString(36).slice(2)}`
yield* fs
.writeFileString(staged, snapshotConfig)
.pipe(
Effect.andThen(fs.rename(staged, file)),
Effect.ignore,
Effect.ensuring(fs.remove(staged, { force: true }).pipe(Effect.ignore)),
)
})
// Private indexes left by killed processes; live captures finish long before the cutoff.
const sweepPrivateIndexes = Effect.fnUntraced(function* (repository: Repository) {
const cutoff = Date.now() - 60 * 60 * 1000
const entries = yield* fs.readDirectory(repository.gitDirectory).pipe(Effect.orElseSucceed(() => []))
yield* Effect.forEach(
entries.filter((entry) => entry.startsWith(privateIndexPrefix)),
(entry) =>
fs.stat(path.join(repository.gitDirectory, entry)).pipe(
Effect.flatMap((info) =>
(Option.getOrUndefined(info.mtime)?.getTime() ?? 0) < cutoff
? fs.remove(path.join(repository.gitDirectory, entry), { force: true })
: Effect.void,
),
Effect.ignore,
),
{ discard: true },
)
})
const treeFiles = Effect.fn("Git.tree.files")(function* (input: {
repository: Repository
from: TreeID
@@ -523,7 +819,7 @@ const layer = Layer.effect(
paths?: readonly RelativePath[]
}) {
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? []).map(literal)]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
@@ -571,7 +867,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
})
})
@@ -581,7 +877,7 @@ const layer = Layer.effect(
"-z",
tree,
"--",
file,
literal(file),
])).text.replace(/\0$/, "")
if (!text) return false
if (!/^\d+\s+\w+\s+[0-9a-f]+\t/.test(text))
@@ -593,35 +889,288 @@ const layer = Layer.effect(
return true
})
const removePath = (repository: Repository, file: RelativePath) =>
fs.remove(path.join(repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
/** Paths that have an entry in `tree`, listed in chunks to stay below argument limits. */
const entries = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, files: readonly RelativePath[]) {
const chunks = Array.from({ length: Math.ceil(files.length / 512) }, (_, index) =>
files.slice(index * 512, (index + 1) * 512),
)
const listed = yield* Effect.forEach(chunks, (chunk) =>
repositoryOperation("restore", repository, ["ls-tree", "-z", tree, "--", ...chunk.map(literal)]).pipe(
Effect.flatMap((result) =>
Effect.forEach(nuls(result.text), (record) => {
const match = /^\d+ \w+ [0-9a-f]+\t(.*)$/s.exec(record)
if (match) return Effect.succeed(match[1])
return Effect.fail(
new OperationError({
operation: "restore",
directory: repository.worktree,
message: `Invalid tree entry: ${record}`,
}),
)
}),
),
),
)
return new Set(listed.flat())
})
/**
* Restores path by path in map order, like the original implementation, unless
* every path is independent. Operations on independent paths commute, so they
* are batched into one ls-tree and one checkout per source tree instead of two
* processes per file.
*/
const restore = Effect.fn("Git.tree.restore")(
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
locked(
input.repository,
Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
Effect.gen(function* () {
if (!input.files.size) return
if (!independent([...input.files.keys()]))
return yield* Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (!(yield* hasEntry(input.repository, tree, file)))
return yield* removePath(input.repository, file)
yield* privateIndex(input.repository, (index) =>
repositoryOperation("restore", input.repository, ["checkout", tree, "--", literal(file)], {
index,
}),
)
}),
{ discard: true },
)
const groups = new Map<TreeID, RelativePath[]>()
input.files.forEach((tree, file) => groups.set(tree, [...(groups.get(tree) ?? []), file]))
const plan = yield* Effect.forEach(groups, ([tree, files]) =>
entries(input.repository, tree, files).pipe(
Effect.map((present) => ({
tree,
present: files.filter((file) => present.has(file)),
absent: files.filter((file) => !present.has(file)),
})),
),
)
yield* Effect.forEach(
plan.flatMap((item) => item.absent),
(file) => removePath(input.repository, file),
{ concurrency: 16, discard: true },
)
const checkouts = plan.filter((item) => item.present.length)
if (!checkouts.length) return
yield* privateIndex(input.repository, (index) =>
Effect.forEach(
checkouts,
(item) =>
repositoryOperation(
"restore",
input.repository,
["checkout", item.tree, "--pathspec-from-file=-", "--pathspec-file-nul"],
{ stdin: item.present.map(literal).join("\0") + "\0", index },
),
)
}),
{ discard: true },
),
{ discard: true },
),
)
}),
),
)
/**
* Snapshot trees have no refs, so Git's own repack and prune would treat every
* snapshot as garbage. Compaction instead hands pack-objects an explicit list of
* every loose object (and, when merging, every object in the old packs), checks
* the new index lists all of them, and only then deletes the loose copies and
* the merged packs. Nothing is pruned, and objects are never delegated to the
* source repository through alternates.
*/
const compact = Effect.fn("Git.objects.compact")(function* (repository: Repository) {
const objects = path.join(repository.gitDirectory, "objects")
const packDirectory = path.join(objects, "pack")
const loose = yield* looseObjects(objects)
const packs = yield* localPacks(packDirectory)
const merge = packs.length >= compactPackLimit
if (loose.length < compactLooseLimit && !merge) return
yield* compactionLock(
repository,
Effect.gen(function* () {
yield* sweepTemporaryPacks(packDirectory)
const merged = merge
? yield* Effect.forEach(packs, (pack) =>
packIndex(repository, path.join(packDirectory, `${pack}.idx`)).pipe(
Effect.map((oids) => ({ pack, oids })),
),
)
: []
const wanted = [...new Set([...loose.map((item) => item.oid), ...merged.flatMap((item) => item.oids)])]
if (!wanted.length) return
const written = (yield* repositoryOperation(
"compact",
repository,
[
"-c",
"pack.threads=2",
"-c",
"pack.windowMemory=64m",
"pack-objects",
"-q",
"--non-empty",
path.join(packDirectory, "pack"),
],
{ stdin: wanted.join("\n") + "\n" },
)).text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
const packed = new Set(
(yield* Effect.forEach(written, (name) =>
packIndex(repository, path.join(packDirectory, `pack-${name}.idx`)),
)).flat(),
)
const missing = wanted.filter((oid) => !packed.has(oid))
if (missing.length)
return yield* new OperationError({
operation: "compact",
directory: repository.gitDirectory,
message: `Packed ${packed.size} objects but ${missing.length} are missing; nothing was removed`,
})
// Deletion is quick and must not stop halfway, which could strand a pack without its index.
yield* Effect.uninterruptible(
Effect.gen(function* () {
yield* Effect.forEach(loose, (item) => fs.remove(item.file, { force: true }).pipe(Effect.ignore), {
concurrency: 16,
discard: true,
})
const kept = new Set(written.map((name) => `pack-${name}`))
yield* Effect.forEach(
merged.filter((item) => !kept.has(item.pack)),
(item) =>
// The index goes first so readers never see an index without its pack.
Effect.forEach(
[".idx", ".pack", ".rev", ".bitmap", ".mtimes"],
(extension) =>
fs
.remove(path.join(packDirectory, `${item.pack}${extension}`), { force: true })
.pipe(Effect.ignore),
{ discard: true },
),
{ discard: true },
)
}),
)
}),
)
})
const looseObjects = Effect.fnUntraced(function* (objects: string) {
const fanout = (yield* fs.readDirectory(objects).pipe(Effect.orElseSucceed(() => []))).filter((entry) =>
/^[0-9a-f]{2}$/.test(entry),
)
const listed = yield* Effect.forEach(
fanout,
(prefix) =>
fs.readDirectory(path.join(objects, prefix)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries
.filter((entry) => /^[0-9a-f]{38}$|^[0-9a-f]{62}$/.test(entry))
.map((entry) => ({ oid: prefix + entry, file: path.join(objects, prefix, entry) })),
),
),
{ concurrency: 16 },
)
return listed.flat()
})
// Packs with a .keep marker or a promisor file belong to someone else's policy and are left alone.
const localPacks = Effect.fnUntraced(function* (directory: string) {
const entries = new Set(yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])))
return [...entries]
.filter((entry) => entry.startsWith("pack-") && entry.endsWith(".pack"))
.map((entry) => entry.slice(0, -".pack".length))
.filter(
(pack) => entries.has(`${pack}.idx`) && !entries.has(`${pack}.keep`) && !entries.has(`${pack}.promisor`),
)
})
const packIndex = Effect.fnUntraced(function* (repository: Repository, file: string) {
const bytes = yield* fs.readFile(file).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "compact",
directory: repository.gitDirectory,
message: `Failed to read ${file}`,
cause,
}),
),
)
const result = yield* repositoryOperation("compact", repository, ["show-index"], { stdin: bytes })
return result.text.split("\n").flatMap((line) => {
const oid = line.split(" ")[1]
return oid ? [oid] : []
})
})
/**
* pack-objects writes tmp_* files and renames them; leftovers mean a killed
* process. A pack without its index is unreadable to Git, so removing one is
* lossless; the age cutoff skips a pack whose index is still being renamed.
*/
const sweepTemporaryPacks = Effect.fnUntraced(function* (directory: string) {
const cutoff = Date.now() - 60 * 60 * 1000
const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []))
const indexed = new Set(entries.filter((entry) => entry.endsWith(".idx")).map((entry) => entry.slice(0, -4)))
yield* Effect.forEach(
entries.filter(
(entry) =>
entry.startsWith("tmp_") ||
(entry.startsWith("pack-") && entry.endsWith(".pack") && !indexed.has(entry.slice(0, -5))),
),
(entry) =>
fs.stat(path.join(directory, entry)).pipe(
Effect.flatMap((info) =>
(Option.getOrUndefined(info.mtime)?.getTime() ?? 0) < cutoff
? fs.remove(path.join(directory, entry), { force: true })
: Effect.void,
),
Effect.ignore,
),
{ discard: true },
)
})
// Cross-process exclusion: concurrent compactions would stay lossless but duplicate work and objects.
const compactionLock = <A, E, R>(repository: Repository, effect: Effect.Effect<A, E, R>) => {
const file = path.join(repository.gitDirectory, "opencode-compact.lock")
return Effect.gen(function* () {
const stale = yield* fs.stat(file).pipe(
Effect.map((info) => (Option.getOrUndefined(info.mtime)?.getTime() ?? 0) < Date.now() - 60 * 60 * 1000),
Effect.orElseSucceed(() => false),
)
if (stale) yield* fs.remove(file, { force: true }).pipe(Effect.ignore)
const acquired = yield* fs.writeFileString(file, String(process.pid), { flag: "wx" }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!acquired) return
yield* effect.pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)))
})
}
const worktreeRun = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repository: Repository,
@@ -702,11 +1251,12 @@ const layer = Layer.effect(
index: { refresh, ignored },
tree: {
capture: captureTree,
write: writeTree,
write: (repository) => writeTree(repository),
files: treeFiles,
diff: treeDiff,
restore,
},
objects: { compact },
})
}),
)
@@ -749,6 +1299,30 @@ function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
/** Pathspec magic that matches exactly one path, so names with `*`, `?`, `[`, or a leading `:` are not patterns. */
function literal(file: string) {
return `:(literal)${file}`
}
/**
* Paths whose restore operations commute: none inside another, and every segment
* printable ASCII that no platform rewrites. Git precomposes Unicode, filesystems
* may fold case, and Windows drops trailing dots and spaces and treats `\` and
* `:` specially, so anything else takes the ordered path.
*/
function independent(files: readonly string[]) {
const canonical = files.every((file) =>
file.split("/").every((part) => /^[\x20-\x7e]+$/.test(part) && !/[\\:]/.test(part) && !/[. ]$/.test(part)),
)
if (!canonical) return false
const folded = new Set(files.map((file) => file.toLowerCase()))
if (folded.size !== files.length) return false
return files.every((file) => {
const parts = file.toLowerCase().split("/")
return parts.slice(1).every((_, index) => !folded.has(parts.slice(0, index + 1).join("/")))
})
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+1 -1
View File
@@ -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 ?? [])),
]
+11 -13
View File
@@ -2,13 +2,12 @@ export * as InstructionBuiltIns from "./builtins.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import type { Session } from "@opencode/schema/session"
import { Global } from "@opencode/util/global"
import { Location } from "../location.js"
import { Instructions } from "./index.js"
export interface Interface {
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
readonly load: () => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
@@ -19,16 +18,24 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
return Service.of({
load: (sessionID) =>
load: () =>
Effect.succeed(
Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Current conversation session ID: ${sessionID}`,
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
@@ -44,15 +51,6 @@ const layer = Layer.effect(
["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
]),
),
})
-3
View File
@@ -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"
-1
View File
@@ -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 = [],
+4 -4
View File
@@ -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],
})
-2
View File
@@ -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
File diff suppressed because one or more lines are too long
@@ -1,55 +1,62 @@
import { Effect } from "effect"
import { isArrayNonEmpty } from "effect/Array"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway")
const accountIdField = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdField = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare.ai.gateway",
effect: Effect.fn(function* (ctx) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
if (typeof configured?.baseURL === "string") return
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ||
stringOption(configured ?? {}, "gatewayId") ||
stringOption(configured ?? {}, "gateway")
if (accountId && gatewayId) return
const accountIdForm = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdForm = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
if (accountId) return Form.Fields.make([gatewayIdForm])
if (gatewayId) return Form.Fields.make([accountIdForm])
return Form.Fields.make([accountIdForm, gatewayIdForm])
})
const fields =
typeof configured?.baseURL === "string"
? []
: [
...(accountId || typeof configured?.accountId === "string" ? [] : [accountIdField]),
...(gatewayId || typeof configured?.gatewayId === "string" ? [] : [gatewayIdField]),
]
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: providerID,
method: {
type: "key",
label: "Gateway API token",
form,
form: isArrayNonEmpty(fields) ? Form.Fields.make(fields) : undefined,
},
})
})
yield* ctx.provider.transform((evt) => {
const item = evt.get(providerID)
if (!item || (!accountId && !gatewayId)) return
evt.update(item.provider.id, (provider) => {
if (typeof provider.settings?.baseURL === "string") return
provider.settings = {
...(accountId ? { accountId } : {}),
...(gatewayId ? { gatewayId } : {}),
...provider.settings,
}
})
})
}),
})
function stringOption(options: Record<string, unknown>, key: string) {
return typeof options[key] === "string" ? options[key] : undefined
}
@@ -50,7 +50,7 @@ export const CloudflareWorkersAIPlugin = define({
})
function resolveAccountId(options: Record<string, unknown>) {
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
return stringOption(options, "accountId") ?? process.env.CLOUDFLARE_ACCOUNT_ID
}
function workersEndpoint(accountId: string) {
@@ -11,6 +11,7 @@ import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
@@ -272,7 +273,7 @@ export const GithubCopilotPlugin = define({
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = evt.sessionID
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },

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