Compare commits

..
2 Commits
116 changed files with 1003 additions and 1309 deletions
+57 -69
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.16",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -135,6 +135,7 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -176,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -407,7 +408,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -456,7 +457,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -493,7 +494,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +510,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -528,7 +529,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -541,7 +542,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -555,7 +556,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -570,7 +571,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -609,7 +610,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -639,7 +640,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -654,7 +655,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -678,7 +679,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -699,7 +700,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -721,7 +722,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -756,7 +757,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -776,7 +777,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -810,7 +811,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -829,7 +830,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -875,7 +876,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -889,7 +890,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -908,6 +909,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
@@ -923,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -980,7 +982,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"open": "11.0.4",
"pacote": "21.5.1",
},
"devDependencies": {
@@ -996,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1037,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1097,7 +1098,6 @@
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"open": "11.0.4",
"solid-js": "catalog:",
},
"catalog": {
@@ -4322,7 +4322,7 @@
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
@@ -4340,8 +4340,6 @@
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
@@ -4388,7 +4386,7 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
@@ -4852,7 +4850,7 @@
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
@@ -4996,8 +4994,6 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
@@ -5818,7 +5814,7 @@
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
@@ -5914,6 +5910,8 @@
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
@@ -6396,6 +6394,8 @@
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -6500,10 +6500,6 @@
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6514,6 +6510,8 @@
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
@@ -6616,6 +6614,8 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook-solidjs-vite/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"storybook-solidjs-vite/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.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-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
@@ -6702,10 +6702,6 @@
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"yaml-language-server/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
@@ -7340,6 +7336,8 @@
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7400,6 +7398,12 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@@ -7670,10 +7674,6 @@
"@astrojs/starlight/@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@astrojs/starlight/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"@astrojs/starlight/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"@astrojs/starlight/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
"@astrojs/starlight/astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
@@ -8076,10 +8076,6 @@
"@opencode/web/@astrojs/cloudflare/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/web/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"@opencode/web/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"@opencode/web/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@opencode/web/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
@@ -8220,10 +8216,6 @@
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"astro-expressive-code/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"astro-expressive-code/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"astro-expressive-code/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astro-expressive-code/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
@@ -8326,10 +8318,6 @@
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"toolbeam-docs-theme/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"toolbeam-docs-theme/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9gJjhes2ueYckAgdeGlPwZcaIDdwB3ZnqK/XHHXhWNs=",
"aarch64-linux": "sha256-Sy5YXYM9tKevIITdV++bP35SJNaFCQVKwlNJRbWsD1Q=",
"aarch64-darwin": "sha256-wiXHjKXm2VIFvalwITpSiRHaFZEWc8UJIqjQyc/0f0s=",
"x86_64-darwin": "sha256-r/mnhdNbnPIJOY3qvtuY6GQ7ed1Nauq65X+8uERhdP8="
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
}
}
+1 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -162,7 +162,6 @@
"@types/node": "catalog:",
"bun-types": "1.4.2",
"effect": "catalog:",
"open": "11.0.4",
"solid-js": "catalog:"
},
"patchedDependencies": {
+4 -4
View File
@@ -12,9 +12,9 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.inlineMedia` / `requireInlineMedia` / `mediaUrl` / `MediaInput.refID` rather than re-deriving base64 or URL handling.
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
@@ -98,7 +98,7 @@ 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`, 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.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.
@@ -112,7 +112,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
### Provider Facades
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, later `.video` / `.speech` / `.transcription`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
+18 -19
View File
@@ -475,18 +475,18 @@ const program = Effect.gen(function* () {
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
call rather than being dropped, so check this table before swapping only the `model`:
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | -------------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`, `/multi`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
@@ -621,7 +621,8 @@ persist the bytes promptly if they must remain available.
### Partial images
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request:
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request.
`dall-e-*` models do not stream and fail typed:
```ts
import { Stream } from "effect"
@@ -698,7 +699,7 @@ const program = Effect.gen(function* () {
})
```
The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
The hosted result is represented as a provider-executed tool call and tool result, and the generated image is also emitted as a first-class `media` `LLMEvent` (`response.message` then carries a `media` part). Gemini image-capable models emit the same `media` event for inline image output. Retaining `response.message` preserves the generated image for continuation on both routes.
## Video generation
@@ -839,10 +840,9 @@ Provider notes:
- **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
and `timestamps` are not supported.
- **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
(`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
`speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
- **Gemini TTS** returns raw 16-bit PCM only (`audio/L16;codec=pcm;rate=24000`), so any `format` other than `pcm`
fails typed; wrap the samples yourself. Style is directed in the text, so `instructions` and `speed` fail typed.
Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
`providerOptions.speechConfig.multiSpeakerVoiceConfig`.
- **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
`output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
@@ -944,7 +944,6 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
+29 -36
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
@@ -41,7 +40,7 @@ The design below is derived from a survey of the raw provider APIs (OpenAI, Gemi
### Model selection
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `openai.image(id)` today, `.video(id)` / `.speech(id)` / `.transcription(id)` as those modalities land — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
```ts
import { OpenAI, Google } from "@opencode/ai/providers"
@@ -136,8 +135,8 @@ Effect.gen(function* () {
})
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
in the README's Image generation section.
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
@@ -167,8 +166,8 @@ Effect.gen(function* () {
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
@@ -176,7 +175,7 @@ Effect.gen(function* () {
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
@@ -189,11 +188,10 @@ Effect.gen(function* () {
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
rejects `n`.
lowering per provider: Veo takes inline media only and rejects `audio: false` and `n > 1`; xAI rejects `seed` and
`negativePrompt` and routes a `video` input to edits or (`providerOptions.mode: "extend"`) extensions; fal rejects
`durationSeconds`, `references`, and `frames.last` because the field names and enums differ per model; Runway passes
`aspectRatio` through as its pixel `ratio` and rejects `n`.
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
@@ -243,12 +241,10 @@ name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfi
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
`operation: "media.format"`.
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini returns PCM only, so any
other `format` is rejected rather than wrapped as WAV by the route. Every `format` value a route cannot produce (unknown
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
@@ -281,7 +277,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, // expected count, hint only (AssemblyAI)
providerOptions: { known_speaker_names: ["agent"] },
})
@@ -295,11 +291,10 @@ yield* Transcription.resume(model, token)
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
`<Options, Frame, State>`, `<Options, Token>`) and composes through `MediaRoute.inline` / `stream` / `queued`; and
`TranscriptionClient` dispatches on `route.kind`. `generate` on a queued route is `start` then `await`; `stream` on an
inline route is the response as a single `finish`, and on a queued route it is the status observations followed by
`finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
`collect` is just "take `finish`".
@@ -314,12 +309,11 @@ Settled rules:
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.
cross-provider speaker model. `speakers` is a hint; only AssemblyAI (`speakers_expected`) accepts it.
- **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.
ISO code on most routes, `english` from whisper-1, `en_us` from AssemblyAI. 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.
@@ -329,7 +323,7 @@ Settled rules:
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|---|---|---|---|---|---|---|
| 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` |
| OpenAI | stream (`stream: true` in `stream` mode) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model; streaming on `whisper-1` | `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`) |
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
@@ -359,7 +353,7 @@ GenerationAwaitOptions = { poll?: Poll }
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`.
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
### Usage
@@ -406,15 +400,14 @@ Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown
### Providers
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
implemented):
Existing facades gain per-modality selectors; the modality routes each facade provides:
| Facade | llm | image | video | speech | transcription | other |
|---|---|---|---|---|---|---|
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
| `OpenAI` | responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
@@ -423,7 +416,7 @@ implemented):
| `Replicate` | | ✓ (queued) | | | | |
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+11 -23
View File
@@ -53,8 +53,6 @@ export type Event = Observation | { readonly type: "generation-finished"; readon
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
export const isTerminal = (status: Status) => TERMINAL.has(status)
export class Generation<Response> {
readonly id: string
readonly status: Status
@@ -83,7 +81,7 @@ export class Generation<Response> {
}
get terminal() {
return isTerminal(this.status)
return TERMINAL.has(this.status)
}
refresh(): Effect.Effect<Generation<Response>, AIError> {
@@ -111,10 +109,9 @@ export class Generation<Response> {
}
/**
* Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
* is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
* the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
* `TestClock` when the source completes while the timer sleeps.)
* Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
* remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
* would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
*/
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
if (this.terminal) return Stream.make(this.event())
@@ -123,26 +120,17 @@ 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),
}),
),
this.refresh().pipe(
Effect.timeoutOrElse({
duration: Duration.millis(Math.max(0, deadline - now)),
orElse: () => this.timeoutError(timeout),
}),
),
),
)
const schedule = this.schedule(options?.poll).pipe(
Schedule.modifyDelay((meta) =>
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
),
)
return Stream.fromEffectSchedule(refresh, schedule).pipe(
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
Stream.takeUntil((generation) => generation.terminal),
Stream.map((generation) => generation.event()),
)
@@ -110,11 +110,8 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
language_code: request.language,
language_detection: request.language === undefined ? true : undefined,
prompt: request.prompt,
// Turn-level `utterances`, the only segments AssemblyAI returns, and `speakers_expected` require speaker labels.
speaker_labels:
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
? true
: undefined,
// Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
speakers_expected: request.speakers,
},
request.providerOptions,
@@ -158,7 +155,8 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
const error = transcript.error ?? undefined
if (status === "failed")
return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`)
if (status !== "completed") return yield* output.pending(context.token.transcriptID)
if (status !== "completed")
return yield* output.invalid(`${route.name} transcript ${context.token.transcriptID} has not finished`)
const duration = transcript.audio_duration ?? undefined
return new TranscriptionResponse({
text: transcript.text ?? "",
+6 -20
View File
@@ -31,21 +31,13 @@ export type Request = ImageRequestFor<BlackForestLabsImageOptions>
// 2. Token and response schemas
// ---------------------------------------------------------------------------
/**
* Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
* credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
*/
export const Token = Schema.Struct({
id: Schema.String,
pollingURL: Schema.String,
cost: Schema.optionalKey(Schema.Number),
})
/** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String })
export type Token = Schema.Schema.Type<typeof Token>
const StartResponse = Schema.Struct({
id: Schema.String,
polling_url: Schema.String,
cost: optionalNull(Schema.Number),
})
const Result = Schema.Struct({
@@ -153,11 +145,7 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
// ---------------------------------------------------------------------------
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: {
id: value.id,
pollingURL: value.polling_url,
...(value.cost === undefined || value.cost === null ? {} : { cost: value.cost }),
},
token: { id: value.id, pollingURL: value.polling_url },
snapshot: { id: value.id, status: "queued" },
}))
@@ -181,16 +169,14 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
if (isModerated(document.status)) return yield* output.contentPolicy(`${route.name} moderated the generation`)
if (status === "failed" || status === "expired")
return yield* output.ended(status, `${route.name} generation ${context.token.id} ended with ${document.status}`)
if (status !== "completed") return yield* output.pending(context.token.id)
if (document.result === undefined || document.result === null)
if (status !== "completed" || document.result === undefined || document.result === null)
return yield* output.invalid(`${route.name} generation ${context.token.id} has no result`)
const { sample, seed, prompt, ...rest } = document.result
// A settled `cost` on the result supersedes the submit-time cost carried on the token.
const cost = document.cost ?? context.token.cost
return new ImageResponse({
// `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
images: [yield* context.materialize(Media.url(sample))],
usage: cost === undefined ? undefined : { type: "credits", credits: cost },
usage:
document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
providerMetadata: {
bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
},
+1 -4
View File
@@ -67,9 +67,6 @@ const queryParameters = (request: Request) => {
}
const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request: Request) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
if (
request.format !== undefined &&
FORMATS[request.format] === undefined &&
@@ -120,7 +117,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
unsupported: ["voice", "language", "instructions"],
unsupported: ["voice", "language", "instructions", "timestamps"],
body: { from: fromRequest },
frames: (bytes) => bytes,
initial: () => ({ chunks: [] }),
+14 -20
View File
@@ -49,7 +49,7 @@ const QueueResult = Schema.StructWithRest(
// ---------------------------------------------------------------------------
const sizing = (model: string) => {
if (/^fal-ai\/(nano-banana|flux-pro\/(v1\.1-ultra|kontext))/.test(model)) return "aspect_ratio"
if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model)) return "aspect_ratio"
if (model.startsWith("fal-ai/flux")) return "image_size"
return undefined
}
@@ -63,24 +63,20 @@ const validate = (request: Request) => {
return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`))
if (request.aspectRatio !== undefined && field === "image_size")
return Effect.fail(route.unsupported("media.aspectRatio", `${id} sizes by size (image_size)`))
if ((request.images?.length ?? 0) > 1 && !takesImageList(id))
if ((request.images?.length ?? 0) > 1 && !isEdit(id))
return Effect.fail(
route.unsupported(
"media.images",
`${id} takes one image_url; use an /edit or /multi endpoint for several images`,
),
route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`),
)
return Effect.void
}
// `/edit` and `/multi` (Kontext) endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one
// `image_url` (beside `mask_url`).
const takesImageList = (model: string) => model.endsWith("/edit") || model.endsWith("/multi")
// `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
const isEdit = (model: string) => model.endsWith("/edit")
const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Request) {
yield* validate(request)
const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name))
const list = takesImageList(request.model.id)
const edit = isEdit(request.model.id)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -90,8 +86,8 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
aspect_ratio: request.aspectRatio,
output_format: request.format,
image_urls: list && images.length > 0 ? images : undefined,
image_url: list ? undefined : images[0],
image_urls: edit && images.length > 0 ? images : undefined,
image_url: edit ? undefined : images[0],
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
},
request.providerOptions,
@@ -116,14 +112,12 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
// With the safety checker on, flagged images come back blacked out rather than omitted.
const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []))
return new ImageResponse({
images: images.map((image) => {
const info = { width: image.width ?? undefined, height: image.height ?? undefined }
// `sync_mode: true` returns data URIs instead of hosted URLs.
return (
Media.parseDataUrl(image.url, { info }) ??
Media.url(image.url, { mediaType: image.content_type ?? undefined, info })
)
}),
images: images.map((image) =>
Media.url(image.url, {
mediaType: image.content_type ?? undefined,
info: { width: image.width ?? undefined, height: image.height ?? undefined },
}),
),
notices:
flagged.length === 0
? undefined
+1 -1
View File
@@ -101,7 +101,7 @@ const generationConfig = (request: Request) => {
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
if (request.n !== undefined && request.n > 1)
return yield* route.unsupported(
"media.n",
"image.n",
`${route.name} generates one image per request; call it once per image instead of n=${request.n}`,
)
const parts = yield* Effect.forEach(request.images ?? [], (image) =>
+1 -4
View File
@@ -56,9 +56,6 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
// ---------------------------------------------------------------------------
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
return yield* route.unsupported(
"media.format",
@@ -128,7 +125,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>(route, {
unsupported: ["instructions", "speed"],
unsupported: ["instructions", "speed", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
initial: () => ({ chunks: [] }),
+2 -1
View File
@@ -149,7 +149,8 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
const output = yield* decodeOperation(response)
const operation = output.value
const status = statusOf(operation)
if (status === "running") return yield* output.pending(context.token.operation)
if (status === "running")
return yield* output.invalid(`${route.name} operation ${context.token.operation} has not finished`)
if (status === "failed")
return yield* output.ended(
"failed",
+24 -54
View File
@@ -48,17 +48,15 @@ const Usage = Schema.Struct({
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
/** What the provider actually rendered; it can differ from the request when `auto` or a default applied. */
const Settings = {
output_format: Schema.optional(Schema.String),
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.String),
background: Schema.optional(Schema.String),
}
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(Schema.Struct({ b64_json: Schema.String })),
...Settings,
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(Usage),
})
@@ -71,13 +69,11 @@ const StreamEvent = Schema.Union([
type: Schema.Literals(["image_generation.partial_image", "image_edit.partial_image"]),
b64_json: Schema.String,
partial_image_index: Schema.Number,
...Settings,
output_format: Schema.String,
}),
Schema.Struct({
type: Schema.Literals(["image_generation.completed", "image_edit.completed"]),
b64_json: Schema.String,
...Settings,
output_format: Schema.String,
usage: Schema.optional(Usage),
}),
@@ -96,9 +92,6 @@ type Frame = string | { readonly document: string; readonly requested: string |
interface State {
readonly completed: number
readonly format?: string
readonly size?: string
readonly quality?: string
readonly background?: string
readonly usage?: MediaUsage
}
@@ -117,6 +110,10 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
const streamOptions = (request: MediaProtocol.Addressed<Request>) => {
if (request.mode !== "stream") return Effect.succeed(undefined)
if (request.model.id.startsWith("dall-e"))
return Effect.fail(
route.unsupported("media.stream", `${request.model.id} does not stream; use Image.generate or a GPT image model`),
)
if (request.n !== undefined && request.n > 1)
return Effect.fail(
route.unsupported("media.n", `${route.name} streams one image; use Image.generate for n=${request.n}`),
@@ -197,34 +194,21 @@ const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage
details: { openai: value },
}
/** `size` echoes the rendered `WIDTHxHEIGHT`; `auto` or any other value leaves the dimensions unknown. */
const info = (format: string, size: string | undefined): Media.Info => {
const match = size?.match(/^(\d+)x(\d+)$/)
return match ? { format, width: Number(match[1]), height: Number(match[2]) } : { format }
}
const eventImage = (frame: string, label: string, data: string, format: string, size: string | undefined) =>
const eventImage = (frame: string, label: string, data: string, format: string) =>
MediaInput.decodedAsset((message, cause) => route.frameError(message, frame, cause), label, data, `image/${format}`, {
info: info(format, size),
info: { format },
})
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
const format = event.output_format
if ("partial_image_index" in event) {
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format, event.size)
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format)
return [state, [ImagePartialEvent.make({ index: event.partial_image_index, image })]] as const
}
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format, event.size)
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format)
return [
{
completed: state.completed + 1,
format,
size: event.size,
quality: event.quality,
background: event.background,
usage: usage(event.usage),
},
{ ...state, completed: state.completed + 1, format, usage: usage(event.usage) },
[ImageOutputEvent.make({ index: state.completed, image })],
] as const
})
@@ -235,20 +219,16 @@ const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclud
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
)
const format = decoded.output_format ?? frame.requested ?? "png"
const mediaType = `image/${format}`
const images = yield* Effect.forEach(decoded.data, (item, index) =>
MediaInput.decodedAsset(invalid, `${route.name} result ${index}`, item.b64_json, `image/${format}`, {
info: info(format, decoded.size),
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
info: { format },
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
if (images.length === 0) return yield* invalid(`${route.name} returned no images`)
const state: State = {
completed: images.length,
format,
size: decoded.size,
quality: decoded.quality,
background: decoded.background,
usage: usage(decoded.usage),
}
const state: State = { completed: images.length, format, usage: usage(decoded.usage) }
return [state, images.map((image, index) => ImageOutputEvent.make({ index, image }))] as const
})
@@ -257,17 +237,7 @@ const step = (state: State, frame: Frame) => (typeof frame === "string" ? onEven
const finish = (state: State) => {
if (state.completed === 0) return Effect.fail(route.incomplete())
return Effect.succeed([
ImageFinishEvent.make({
usage: state.usage,
providerMetadata: {
openai: {
outputFormat: state.format,
size: state.size,
quality: state.quality,
background: state.background,
},
},
}),
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
])
}
@@ -143,14 +143,12 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
// `-pro` slugs), and never alongside automatic `context_management` compaction.
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
+1 -4
View File
@@ -61,9 +61,6 @@ interface State extends SpeechStream.Audio {
const supportsSse = (model: string) => !/^tts-1(-hd)?(-|$)/.test(model)
const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
if (request.timestamps === true)
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -124,7 +121,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
unsupported: ["language"],
unsupported: ["language", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
initial: () => ({ chunks: [], done: false }),
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { classifyProviderFailure } from "../provider-error.js"
import { Framing } from "../route/framing.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { AIError, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import {
TranscriptionFinishEvent,
TranscriptionModel,
@@ -60,9 +59,6 @@ const Usage = Schema.Union([
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_token_details: Schema.optional(
Schema.Struct({ audio_tokens: Schema.optional(Schema.Number), text_tokens: Schema.optional(Schema.Number) }),
),
}),
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
])
@@ -79,23 +75,14 @@ const transcriptFields = {
usage: Schema.optional(Usage),
}
/** OpenAI may add stream event types; frames outside `EVENT_TYPES` are ignored. */
const EventType = Schema.Struct({ type: Schema.String })
const Event = Schema.Union([
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
Schema.Struct({
type: Schema.Literal("error"),
message: Schema.optional(Schema.String),
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
}),
])
const EVENT_TYPES = new Set(["transcript.text.delta", "transcript.text.segment", "transcript.text.done", "error"])
const Transcript = Schema.Struct(transcriptFields)
type Transcript = Schema.Schema.Type<typeof Transcript>
const decodeEventType = route.decodeFrame(EventType)
const decodeEvent = route.decodeFrame(Event)
const decodeTranscript = route.decodeFrame(Transcript)
@@ -131,12 +118,10 @@ const capabilities = (model: string): Capabilities => {
return TRANSCRIBE
}
/** whisper-1 ignores `stream`, so its `stream` mode sends a plain request and emits only `finish`. */
const streamsEvents = (request: MediaProtocol.Addressed<Request>) =>
request.mode === "stream" && capabilities(request.model.id).stream
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
const id = request.model.id
if (request.mode === "stream" && !model.stream)
return Effect.fail(route.unsupported("media.stream", `${id} does not stream; use Transcription.generate`))
if (request.diarize === true && !model.diarize)
return Effect.fail(route.unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`))
if (request.prompt !== undefined && model.diarize)
@@ -188,7 +173,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
// Diarizing audio longer than 30 seconds requires a chunking strategy.
chunking_strategy: model.diarize ? "auto" : undefined,
stream: streamsEvents(request) ? true : undefined,
stream: request.mode === "stream" ? true : undefined,
},
{
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
@@ -211,15 +196,7 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
})
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
const event = yield* decodeEvent(frame)
if (event.type === "error")
return yield* new AIError({
reason: classifyProviderFailure({
message: `${route.name} stream failed: ${event.message ?? event.error?.message ?? "unknown error"}`,
rawBody: frame,
}),
})
if (event.type === "transcript.text.done") return [{ ...state, transcript: event }, []] as const
if (event.type === "transcript.text.delta")
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]] as const
@@ -269,7 +246,7 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame,
unsupported: ["speakers"],
body: { from: fromRequest },
frames: (bytes, context) =>
streamsEvents(context.request)
context.request.mode === "stream"
? Framing.sse.frame(bytes)
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
initial: () => ({ segments: [] }),
@@ -132,7 +132,8 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
status,
`${route.name} prediction ${context.token.id} ${prediction.status}${typeof prediction.error === "string" ? `: ${prediction.error}` : ""}`,
)
if (status !== "completed") return yield* output.pending(context.token.id)
if (status !== "completed")
return yield* output.invalid(`${route.name} prediction ${context.token.id} has not finished`)
if (prediction.data_removed === true)
return yield* output.ended("expired", `${route.name} removed the output of prediction ${context.token.id}`)
if (!isOutput(prediction.output))
+3 -2
View File
@@ -141,7 +141,8 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
}
if (status === "cancelled")
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
if (status !== "completed") return yield* output.pending(context.token.taskID)
if (status !== "completed")
return yield* output.invalid(`${route.name} task ${context.token.taskID} has not finished`)
const urls = task.output ?? []
if (urls.length === 0) return yield* output.invalid(`${route.name} task succeeded without any output`)
return new VideoResponse({
@@ -170,7 +171,7 @@ export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(rout
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: taskPath, decode: decodeStatus },
result: { path: taskPath, decode: decodeResult },
cancel: { method: "DELETE", path: taskPath, activeOnly: true },
cancel: { method: "DELETE", path: taskPath },
})
const startPath = (request: Request) => {
@@ -175,7 +175,7 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
) {
if (response.status === 202) {
const output = yield* upscaleRoute.text(response)
return yield* output.pending(context.token.id)
return yield* output.invalid(`${upscaleRoute.name} upscale ${context.token.id} has not finished`)
}
return yield* decodeUpscaleImage(response)
})
+1 -2
View File
@@ -101,8 +101,7 @@ const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
)
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
// xAI reports a USD cost (`cost_in_usd_ticks`) rather than tokens, seconds, or credits; the raw record stays in
// provider metadata.
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
return new ImageResponse({
images,
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
+2 -1
View File
@@ -136,7 +136,8 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
const output = yield* decodeVideoStatus(response)
const decoded = output.value
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
if (status === "running") return yield* output.pending(context.token.requestID)
if (status === "running")
return yield* output.invalid(`${route.name} request ${context.token.requestID} has not finished`)
if (status === "failed") {
const code = decoded.error?.code ?? undefined
const message = decoded.error?.message ?? undefined
+3 -3
View File
@@ -1,6 +1,7 @@
import { Duration, Effect, Schema } from "effect"
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
@@ -8,7 +9,6 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
const route = MediaProtocol.identity({ id: "zai-images", name: "Z.ai Images", provider: "zai" })
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
export const PATH = "/images/generations"
const OUTPUT_RETENTION = Duration.days(30)
// ---------------------------------------------------------------------------
// 1. Public model input
@@ -76,7 +76,7 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
const filters = decoded.content_filter ?? []
return new ImageResponse({
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
images: yield* Effect.forEach(decoded.data, (item) => MediaProtocol.expiringUrl(item.url, OUTPUT_RETENTION)),
images: decoded.data.map((item) => Media.url(item.url)),
// Z.ai reports applied content filters alongside a successful result; surface them instead of dropping them.
notices:
filters.length === 0
+4 -17
View File
@@ -137,11 +137,6 @@ export interface Queued<Request, Response, Token> {
readonly cancel?: {
readonly method: AuthInput["method"]
readonly path: (token: Token) => string
/**
* Fetch a fresh status first and skip the call for terminal generations, for providers whose cancel endpoint
* destroys finished work (Runway's `DELETE /v1/tasks/{id}` deletes completed tasks and their outputs).
*/
readonly activeOnly?: boolean
}
}
@@ -200,8 +195,7 @@ 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.
* provider-side, `cancelled`/`expired` mean the result will never exist); `contentPolicy` is a moderated result.
*/
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
const http = context(response)
@@ -230,14 +224,6 @@ export const identity = (input: { readonly id: string; readonly name: string; re
? new ProviderInternalError({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
pending: (id: string) =>
new AIError({
reason: new InvalidRequestError({
message: `${input.name} generation ${id} has not finished; await it before reading the result`,
body,
http,
}),
}),
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
}
})
@@ -299,8 +285,9 @@ export const status = <Table extends Record<string, Status>>(
raw: string,
output: Output,
): Effect.Effect<Status, AIError> => {
if (!Object.hasOwn(table, raw)) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
return Effect.succeed(table[raw])
const normalized: Status | undefined = table[raw]
if (normalized === undefined) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
return Effect.succeed(normalized)
}
/** A `url` asset whose provider-declared retention window starts now. */
+6 -15
View File
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
import { RequestExecutorService, type Interface } from "./executor-service.js"
import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, isTerminal } from "../generation.js"
import { Generation } from "../generation.js"
import type { Media } from "../media.js"
import {
AIError,
@@ -164,19 +164,14 @@ export const queued = <Request extends MediaRequest, Response, Token>(
transport
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
const status = poll(protocol.status)
const cancel = protocol.cancel
const send =
cancel === undefined
? undefined
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid)
return {
status,
status: poll(protocol.status),
result: poll(protocol.result),
cancel:
send !== undefined && cancel?.activeOnly
? status.pipe(Effect.flatMap((snapshot) => (isTerminal(snapshot.status) ? Effect.void : send)))
: send,
cancel === undefined
? undefined
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
}
}
@@ -385,11 +380,7 @@ const encode = (body: MediaProtocol.Body | undefined, headers: Headers.Headers)
}
}
/**
* Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. `false`
* counts as present because some booleans mean something when false (video `audio`); protocols reject opt-in
* booleans such as speech `timestamps` with `=== true` in `body.from` instead of listing them.
*/
/** Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. */
const rejectUnsupported = <Request extends object>(
route: string,
provider: ProviderID,
-24
View File
@@ -351,34 +351,10 @@ describe("OpenAI Responses effort updates", () => {
}),
)
it.effect("strips markers when the body overlay selects pro reasoning mode", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture", http: { body: { reasoning: { mode: "pro" } } } }).responses(
"gpt-6-sol",
),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
}),
)
for (const [id, supported] of [
["gpt-6-astra", true],
["openai/gpt-6-astra", true],
["gpt-6-sol", true],
["openai/gpt-6-sol", true],
["gpt-6-luna", true],
["openai/gpt-6-luna", true],
["gpt-6-astra-2026-09-01", false],
["gpt-6-sol-pro", false],
["gpt-6-luna-pro", false],
["gpt-6-sol-fast", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
-31
View File
@@ -23,7 +23,6 @@ import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
AssemblyAI,
Baseten,
BlackForestLabs,
Cartesia,
CloudflareAIGateway,
CloudflareWorkersAI,
@@ -33,18 +32,14 @@ import {
Fal,
Fireworks,
Google,
Meta,
OpenCodeZen,
OpenAI,
OpenAICompatible,
OpenRouter,
Replicate,
Runway,
Stability,
TypeSafeAI,
VercelAIGateway,
XAI,
ZAI,
} from "@opencode/ai/providers"
import {
OpenAIChat,
@@ -156,34 +151,8 @@ describe("public exports", () => {
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(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")
expect(Google.provider.image).toBe(Google.image)
expect(XAI.configure({ apiKey: "fixture" }).image("grok-imagine-image").route.id).toBe("xai-images")
expect(XAI.provider.image).toBe(XAI.image)
expect(Fal.configure({ apiKey: "fixture" }).image("fal-ai/flux/dev").route.id).toBe("fal-images")
expect(Fal.provider.image).toBe(Fal.image)
expect(BlackForestLabs.configure({ apiKey: "fixture" }).image("flux-2-pro").route.id).toBe("bfl-images")
expect(BlackForestLabs.provider.image).toBe(BlackForestLabs.image)
expect(Replicate.configure({ apiKey: "fixture" }).image("black-forest-labs/flux-schnell").route.id).toBe(
"replicate-images",
)
expect(Replicate.provider.image).toBe(Replicate.image)
expect(Stability.configure({ apiKey: "fixture" }).image("sd3.5-large").route.id).toBe("stability-images")
expect(Stability.provider.image).toBe(Stability.image)
expect(Stability.configure({ apiKey: "fixture" }).upscale().route.id).toBe("stability-upscale")
expect(Stability.provider.upscale).toBe(Stability.upscale)
expect(Meta.configure({ apiKey: "fixture" }).image("muse-image").route.id).toBe("meta-images")
expect(Meta.provider.image).toBe(Meta.image)
expect(ZAI.configure({ apiKey: "fixture" }).image("glm-image").route.id).toBe("zai-images")
expect(ZAI.provider.image).toBe(ZAI.image)
expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
expect(XAI.provider.video).toBe(XAI.video)
expect(Google.configure({ apiKey: "fixture" }).video("veo-3.1-generate-preview").route.id).toBe("google-video")
expect(Google.provider.video).toBe(Google.video)
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
expect(Fal.provider.video).toBe(Fal.video)
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
expect(Runway.provider.video).toBe(Runway.video)
expect(OpenAI.configure({ apiKey: "fixture" }).speech("gpt-4o-mini-tts").route.id).toBe("openai-speech")
-20
View File
@@ -97,26 +97,6 @@ describe("Generation", () => {
}),
)
it.effect("fails an event stream at the deadline when the poll interval is longer than the timeout", () =>
Effect.gen(function* () {
const scripted = yield* scriptedRoute(["running"], "never")
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "queued" })
const fiber = yield* Effect.forkChild(
generation
.events({ poll: { interval: "30 seconds", timeout: "10 seconds" } })
.pipe(Stream.runCollect, Effect.flip),
)
yield* TestClock.adjust("9 seconds")
expect(fiber.pollUnsafe()).toBeUndefined()
yield* TestClock.adjust("1 second")
const error = yield* Fiber.join(fiber)
expect(error.reason._tag).toBe("Timeout")
expect(yield* Ref.get(scripted.polls)).toBe(1)
}),
)
it.effect("surfaces the route failure body for failed generations", () =>
Effect.gen(function* () {
const scripted = yield* scriptedRoute(["running", "failed"], "unused")
+4 -135
View File
@@ -78,11 +78,8 @@ describe("Image", () => {
mediaType: "image/webp",
})
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image.info).toEqual({ format: "webp", width: 2048, height: 2048 })
expect(response.image.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
expect(response.providerMetadata).toEqual({
openai: { outputFormat: "webp", size: "2048x2048", quality: "high", background: "opaque" },
})
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
@@ -110,11 +107,8 @@ describe("Image", () => {
})
return input.respond(
JSON.stringify({
data: [{ b64_json: "AQID" }, { b64_json: "BAUG" }],
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
output_format: "webp",
size: "2048x2048",
quality: "high",
background: "opaque",
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
}),
{ headers: { "content-type": "application/json" } },
@@ -150,7 +144,6 @@ describe("Image", () => {
),
)
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/png" })
expect(response.image.info).toEqual({ format: "png" })
}),
)
@@ -732,7 +725,6 @@ describe("Image", () => {
const errors = yield* Effect.all(
[
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }),
Image.start({
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
prompt,
@@ -743,6 +735,7 @@ describe("Image", () => {
prompt,
size: "512x512",
}),
Stream.runCollect(Image.stream({ model: openai.image("dall-e-3"), prompt })),
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
Image.start({ model: replicate, prompt, seed: 7 }),
Image.start({
@@ -756,9 +749,9 @@ describe("Image", () => {
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "image.start"],
["UnsupportedOperation", "media.n"],
["UnsupportedOperation", "media.aspectRatio"],
["UnsupportedOperation", "media.size"],
["UnsupportedOperation", "media.stream"],
["UnsupportedOperation", "media.n"],
["UnsupportedOperation", "media.seed"],
["InvalidRequest", false],
@@ -768,80 +761,6 @@ describe("Image", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
const falToken = {
requestID: "r1",
statusURL: "https://queue.fal.test/fal-ai/flux/requests/r1/status",
responseURL: "https://queue.fal.test/fal-ai/flux/requests/r1",
cancelURL: "https://queue.fal.test/fal-ai/flux/requests/r1/cancel",
}
const falSubmitted = {
request_id: falToken.requestID,
status_url: falToken.statusURL,
response_url: falToken.responseURL,
cancel_url: falToken.cancelURL,
}
const bodies: Array<unknown> = []
it.effect("sizes fal Kontext by aspect ratio and sends several images to /multi", () =>
Effect.gen(function* () {
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
const images = [Media.url("https://example.test/a.png"), Media.url("https://example.test/b.png")]
const rejected = yield* Image.start({
model: fal.image("fal-ai/flux-pro/kontext"),
prompt: "A lighthouse",
size: "512x512",
}).pipe(Effect.flip)
yield* Image.start({
model: fal.image("fal-ai/flux-pro/kontext"),
prompt: "A lighthouse",
images: images.slice(0, 1),
aspectRatio: "16:9",
})
yield* Image.start({ model: fal.image("fal-ai/flux-pro/kontext/max/multi"), prompt: "A lighthouse", images })
expect(rejected.reason).toMatchObject({ _tag: "UnsupportedOperation", operation: "media.size" })
expect(bodies).toEqual([
{ prompt: "A lighthouse", aspect_ratio: "16:9", image_url: "https://example.test/a.png" },
{ prompt: "A lighthouse", image_urls: ["https://example.test/a.png", "https://example.test/b.png"] },
])
}).pipe(
Effect.provide(
layer((input) => {
bodies.push(JSON.parse(input.text))
return Effect.succeed(json(input, falSubmitted))
}),
),
),
)
it.effect("decodes fal sync_mode data URIs as inline images", () =>
Effect.gen(function* () {
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
const response = yield* generation.await()
expect(response.images.map((image) => image.source)).toEqual([
{ type: "base64", data: "AQID", mediaType: "image/png" },
{ type: "url", url: "https://v3.fal.media/out.jpg", mediaType: "image/jpeg" },
])
expect(response.image.info).toEqual({ width: 512, height: 512 })
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.url === falToken.statusURL
? json(input, { status: "COMPLETED" })
: json(input, {
images: [
{ url: "data:image/png;base64,AQID", width: 512, height: 512, content_type: "image/png" },
{ url: "https://v3.fal.media/out.jpg", width: 512, height: 512, content_type: "image/jpeg" },
],
}),
),
),
),
),
)
const moderated = { id: "req_1", status: "Content Moderated" }
const prediction = {
id: "p_1",
@@ -849,56 +768,6 @@ describe("Image", () => {
output: { text: "not an image" },
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
}
for (const pending of [
{
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
token: { id: "req_1", pollingURL: "https://bfl.test/v1/get_result?id=req_1" },
status: 200,
body: { id: "req_1", status: "Pending" },
message: "Black Forest Labs generation req_1",
},
{
model: Replicate.configure({ apiKey: "test" }).image("owner/model"),
token: { id: "p_1", getURL: "https://replicate.test/p_1", cancelURL: "https://replicate.test/p_1/cancel" },
status: 200,
body: {
id: "p_1",
status: "processing",
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
},
message: "Replicate generation p_1",
},
{
model: Stability.configure({ apiKey: "test", baseURL: "https://stability.test" }).upscale(),
token: { id: "up_1" },
status: 202,
body: { id: "up_1", status: "in-progress" },
message: "Stability AI generation up_1",
},
]) {
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
Effect.gen(function* () {
const generation = yield* Image.resume(pending.model, pending.token)
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe(`${pending.message} has not finished; await it before reading the result`)
expect(error.reason.body).toBe(JSON.stringify(pending.body))
expect(error.reason.http?.status).toBe(pending.status)
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.respond(JSON.stringify(pending.body), {
status: pending.status,
headers: { "content-type": "application/json" },
}),
),
),
),
),
)
}
it.effect("classifies terminal outcomes the recordings never saw", () =>
Effect.gen(function* () {
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
+1 -51
View File
@@ -3,7 +3,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Ref, Schema } from "effect"
import { FileSystem } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { AIError, Media, Message } from "../src/index.js"
import { Media, Message } from "../src/index.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, scriptedResponses } from "./lib/http.js"
@@ -161,56 +161,6 @@ describe("Media", () => {
}),
)
it.effect("keeps transient url download headers out of toJSON and AssetSchema encoding", () =>
Effect.sync(() => {
const asset = Media.url("https://cdn.example.test/video.mp4", {
mediaType: "video/mp4",
expiresAt: 42,
headers: { "x-goog-api-key": "secret" },
})
expect(asset.headers).toEqual({ "x-goog-api-key": "secret" })
const source = { type: "url", url: "https://cdn.example.test/video.mp4", mediaType: "video/mp4", expiresAt: 42 }
expect(asset.toJSON()).not.toHaveProperty("headers")
expect(JSON.stringify(asset)).not.toContain("secret")
expect(asset.toJSON().source).toEqual(source)
const encoded = Schema.encodeSync(Media.AssetSchema)(asset)
expect(encoded).not.toHaveProperty("headers")
expect(encoded.source).toEqual(source)
const codec = Schema.fromJsonString(Media.AssetSchema)
const json = Schema.encodeSync(codec)(asset)
expect(json).not.toContain("secret")
const restored = Schema.decodeSync(codec)(json)
expect(restored).toBeInstanceOf(Media.Asset)
expect(restored.source).toEqual(source)
expect(restored.expiresAt).toBe(42)
expect(restored.headers).toBeUndefined()
}),
)
it.effect("fails url downloads with non-2xx status as a typed AIError keeping http and body", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: { message: "file expired" } })
const error = yield* Media.url("https://cdn.example.test/expired.png")
.bytes()
.pipe(
Effect.flip,
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(input.respond(body, { status: 404, headers: { "content-type": "application/json" } })),
),
),
)
expect(error).toBeInstanceOf(AIError)
expect(error.message).toContain("file expired")
expect(error.reason.http?.status).toBe(404)
expect(error.reason.http?.url).toBe("https://cdn.example.test/expired.png")
expect(error.reason.body).toBe(body)
}),
)
it.effect("reads files with sniffed media types and writes materialized assets", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -33,8 +33,6 @@ describe("Black Forest Labs Images recorded", () => {
expect(response.image.source.type).toBe("bytes")
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
// BFL reports cost on submit only; the Ready result omits it.
expect(response.usage).toEqual({ type: "credits", credits: 1.4000000000000001 })
}),
{ timeout: 15 * 60 * 1000 },
)
@@ -29,11 +29,7 @@ describe("OpenAI Images recorded", () => {
expect(response.images).toHaveLength(1)
expect(response.image.mediaType).toBe("image/jpeg")
expect(response.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
expect((yield* response.image.bytes()).length).toBeGreaterThan(0)
expect(response.providerMetadata).toEqual({
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
})
}),
)
@@ -80,13 +76,8 @@ describe("OpenAI Images recorded", () => {
expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"])
const image = events.find(ImageEvent.is.image)
expect(image?.image.mediaType).toBe("image/jpeg")
expect(image?.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
expect(dimensions(yield* image!.image.bytes())).toEqual({ width: 1024, height: 1024 })
const finish = events.find(ImageEvent.is.finish)
expect(finish?.usage).toMatchObject({ type: "tokens" })
expect(finish?.providerMetadata).toEqual({
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
})
expect(events.find(ImageEvent.is.finish)?.usage).toMatchObject({ type: "tokens" })
}),
)
})
@@ -44,12 +44,7 @@ describe("OpenAI Transcription recorded", () => {
expect(deltas.length).toBeGreaterThan(1)
expect(deltas.join("")).toBe(finish.text)
expect(finish.text).toMatch(TRANSCRIPT)
expect(finish.usage).toMatchObject({
type: "tokens",
input: expect.any(Number),
output: expect.any(Number),
details: { openai: { input_token_details: { audio_tokens: expect.any(Number) } } },
})
expect(finish.usage).toMatchObject({ type: "tokens", input: expect.any(Number), output: expect.any(Number) })
}),
)
+1 -6
View File
@@ -32,12 +32,7 @@ describe("Z.ai Images", () => {
expect(response.images).toHaveLength(1)
expect(response.image.mediaType).toBe("application/octet-stream")
// Z.ai documents that output URLs expire 30 days after generation; the test clock starts at 0.
expect(response.image.source).toEqual({
type: "url",
url: "https://cdn.z.ai/generated.png",
expiresAt: 30 * 24 * 60 * 60 * 1000,
})
expect(response.image.source).toEqual({ type: "url", url: "https://cdn.z.ai/generated.png" })
expect(response.notices).toEqual([
{
type: "moderated",
-36
View File
@@ -97,42 +97,6 @@ describe("Speech", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("treats timestamps: false as not asking for timestamps on routes that cannot return them", () =>
Effect.gen(function* () {
const bytes = Uint8Array.from([1, 2, 3])
const gemini = JSON.stringify({
candidates: [
{ content: { parts: [{ inlineData: { mimeType: "audio/L16;codec=pcm;rate=24000", data: "AQID" } }] } },
],
})
const responses = yield* Effect.all([
Speech.generate({ model: openai, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
),
Speech.generate({ model: google, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(gemini, "application/json")),
),
Speech.generate({ model: deepgram, text: "Hi", timestamps: false }).pipe(
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
),
])
for (const response of responses) expect(yield* response.audio.bytes()).toEqual(bytes)
const errors = yield* Effect.all(
[openai, google, deepgram].map((model) =>
Speech.generate({ model, text: "Hi", timestamps: true }).pipe(Effect.flip),
),
).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network"))))
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.timestamps"],
],
)
}),
)
it.effect("classifies stream failures and keeps the provider payload and HTTP context", () =>
Effect.gen(function* () {
const badFrame = JSON.stringify({ type: "speech.audio.delta", audio: "not base64!" })
+3 -103
View File
@@ -5,7 +5,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { Media, Transcription, TranscriptionClient } from "../src/index.js"
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
import { dynamicResponse } from "./lib/http.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
@@ -25,6 +25,7 @@ describe("Transcription", () => {
Effect.gen(function* () {
const errors = yield* Effect.all(
[
Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })),
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, diarize: true }),
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, timestamps: "word" }),
Transcription.generate({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, prompt: "Names" }),
@@ -52,6 +53,7 @@ describe("Transcription", () => {
)
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.stream"],
["UnsupportedOperation", "media.diarize"],
["UnsupportedOperation", "media.timestamps"],
["UnsupportedOperation", "media.prompt"],
@@ -68,67 +70,6 @@ describe("Transcription", () => {
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("ignores unknown OpenAI stream events and fails on an error event with the frame", () =>
Effect.gen(function* () {
const sse = (...frames: ReadonlyArray<string>) => frames.map((frame) => `data: ${frame}\n\n`).join("")
const failure = `{"type":"error","error":{"type":"server_error","code":"server_error","message":"The server had an error"}}`
const bodies = [
sse(
`{"type":"transcript.text.delta","delta":"Hi"}`,
`{"type":"transcript.text.future","payload":1}`,
`{"type":"transcript.text.done","text":"Hi"}`,
"[DONE]",
),
sse(`{"type":"transcript.text.delta","delta":"Hi"}`, failure),
]
const model = openai.transcription("gpt-4o-mini-transcribe")
const program = Effect.gen(function* () {
const events = Array.from(yield* Stream.runCollect(Transcription.stream({ model, audio })))
const error = yield* Stream.runCollect(Transcription.stream({ model, audio })).pipe(Effect.flip)
return { events, error }
})
const { events, error } = yield* program.pipe(
Effect.provide(
layer((input) =>
Effect.sync(() =>
input.respond(bodies.shift() ?? "", { headers: { "content-type": "text/event-stream" } }),
),
),
),
)
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", body: failure })
expect(error.message).toContain("The server had an error")
}),
)
it.effect("streams whisper-1 as a single finish from a plain request", () =>
Effect.gen(function* () {
const bodies: Array<string> = []
const events = Array.from(
yield* Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })).pipe(
Effect.provide(
layer((input) =>
Effect.sync(() => {
bodies.push(input.text)
return input.respond(
JSON.stringify({ text: "Hello there.", usage: { type: "duration", seconds: 2 } }),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
)
expect(bodies[0]).not.toContain('name="stream"')
expect(events).toEqual([
expect.objectContaining({ type: "finish", text: "Hello there.", usage: { type: "seconds", seconds: 2 } }),
])
}),
)
it.effect(
"uploads inline audio to AssemblyAI, resumes polling from a persisted token, and surfaces failed transcripts",
() =>
@@ -230,45 +171,4 @@ describe("Transcription", () => {
expect(failure.reason).toMatchObject({ _tag: "ProviderInternal", body: failed })
}),
)
it.effect("enables AssemblyAI speaker labels when only an expected speaker count is given", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
yield* Transcription.start({ model: assemblyai, audio: Media.url("https://a.test/call.mp3"), speakers: 2 }).pipe(
Effect.provide(
layer((input) => observe(calls, input).pipe(Effect.as(json(input, { id: "tr_1", status: "queued" })))),
),
)
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
{
audio_url: "https://a.test/call.mp3",
speech_models: ["universal-3-5-pro"],
language_detection: true,
speaker_labels: true,
speakers_expected: 2,
},
])
}),
)
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
Effect.gen(function* () {
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe("AssemblyAI generation tr_1 has not finished; await it before reading the result")
expect(error.reason.body).toBe(JSON.stringify({ id: "tr_1", status: "processing" }))
expect(error.reason.http?.status).toBe(200)
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.respond(JSON.stringify({ id: "tr_1", status: "processing" }), {
headers: { "content-type": "application/json" },
}),
),
),
),
),
)
})
+2 -80
View File
@@ -576,7 +576,7 @@ describe("Video / Runway", () => {
const model = runway.video("gen4.5")
const taskUrl = "https://runway.test/v1/tasks/task_1"
it.effect("submits image_to_video, polls the task, reports credits, and keeps the finished task on cancel", () =>
it.effect("submits image_to_video with the API version header, polls the task, and reports credits", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const program = Effect.gen(function* () {
@@ -624,7 +624,7 @@ describe("Video / Runway", () => {
return json(input, { id: "task_1", estimatedCost: { credits: 25 } })
}
expect(call.url).toBe(taskUrl)
if (call.method === "DELETE") return yield* Effect.die("cancel deleted a finished Runway task")
if (call.method === "DELETE") return input.respond(null, { status: 204 })
if (nth === 1) return json(input, { id: "task_1", status: "PENDING", estimatedCost: { credits: 25 } })
if (nth === 2) return json(input, { id: "task_1", status: "THROTTLED", estimatedCost: { credits: 25 } })
if (nth === 3) return json(input, { id: "task_1", status: "RUNNING", progress: 0.5 })
@@ -653,32 +653,6 @@ describe("Video / Runway", () => {
`GET ${taskUrl}`,
`GET ${taskUrl}`,
`GET ${taskUrl}`,
`GET ${taskUrl}`,
])
}),
)
it.effect("cancels a task that is still running", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
yield* Effect.gen(function* () {
const generation = yield* Video.start({ model, prompt: "x" })
yield* generation.cancel()
}).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 input.respond(null, { status: 204 })
return json(input, { id: "task_1", status: "RUNNING", progress: 0.2 })
}),
),
),
)
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"POST https://runway.test/v1/text_to_video",
`GET ${taskUrl}`,
`DELETE ${taskUrl}`,
])
}),
@@ -838,55 +812,3 @@ describe("Video / Runway", () => {
}),
)
})
// ---------------------------------------------------------------------------
// Shared queued behavior
// ---------------------------------------------------------------------------
describe("Video / queued result", () => {
for (const pending of [
{
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
token: { operation: "models/veo-3.1/operations/op_1" },
body: { name: "models/veo-3.1/operations/op_1", done: false },
name: "Google Veo",
},
{
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
token: { requestID: "req_1" },
body: { status: "pending", progress: 40 },
name: "xAI Video",
},
{
model: Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5"),
token: { taskID: "task_1" },
body: { status: "RUNNING", progress: 0.5 },
name: "Runway",
},
]) {
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
Effect.gen(function* () {
const generation = yield* Video.resume(pending.model, pending.token)
const error = yield* generation.result().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toBe(
`${pending.name} generation ${generation.id} has not finished; await it before reading the result`,
)
expect(error.reason.body).toBe(JSON.stringify(pending.body))
expect(error.reason.http?.status).toBe(200)
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, pending.body))))),
)
}
it.effect("rejects a status that only matches an inherited property", () =>
Effect.gen(function* () {
const error = yield* Video.resume(
XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
{ requestID: "req_1" },
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toBe('Unknown generation status "constructor"')
expect(error.reason.body).toBe(JSON.stringify({ status: "constructor" }))
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { status: "constructor" }))))),
)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.16",
"description": "",
"type": "module",
"exports": {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"bin": {
@@ -41,6 +41,7 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+2 -2
View File
@@ -16,8 +16,8 @@ export function requireInteractive(message: string) {
}
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
const browser = yield* Effect.promise(() => import("@opencode/util/open"))
yield* Effect.promise(() => browser.openUrl(url)).pipe(Effect.ignore)
const { default: open } = yield* Effect.promise(() => import("open"))
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
})
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+40 -16
View File
@@ -34,10 +34,20 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
shadowable by program declarations like other globals.
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
- [x] The timeout fires between interpreter steps, so one built-in is bounded in what it may build: strings up to
2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`), arrays up to
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`, `replace` and
`replaceAll` with a string replacement (checked before the host builds the result), `encodeURI*`, `btoa`,
`Uint8Array` `toString`/`toBase64`/`toHex`, `URLSearchParams.toString`), arrays up to 10,000,000 elements
(`Array(n)`, `length =`, `Array.from`, spread, rest, `split`, `matchAll`, `concat`, `flat`; below the JS maximum
of 2^32 - 1), 250,000 arguments to one call (spread arguments, `apply`, bound arguments), and 10,000 pending
promises at once. Exceeding one throws a `RangeError`. A replacement whose quick bound is too big is measured
first: the check is exact for a global replacement without `$`, and otherwise charges each `$` token the longest
group of its match (a whole subject for `` $` `` and `$'`), so a result near the cap may still be rejected.
`encodeURI*` and `Uint8Array.toString` are checked after the host builds the string (at most nine times a capped
input). Unhandled rejections from un-awaited promises are reported individually up to 100, each message cut at
4,096 characters, with one summary warning counting the rest that were never handled.
- [ ] A single regular expression match can still run long on a pathological pattern (`/a*a*a*a*b/` on a 1 KB
subject): the host regex engine has no interrupt hook and no subject-length cap helps beyond quadratic patterns,
so this needs a step-counted regex engine of our own.
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
@@ -55,7 +65,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Tagged templates: a tag applied to a template literal is called as `tag(strings, ...values)`, with the tag read
like a callee so a member tag keeps its receiver. `strings` is an array of the cooked text with a read-only `raw`
array of the source text; an invalid escape such as `\unicode` cooks to `undefined`. One template object per
site, as in JS, but it is not frozen: `strings[0] = "x"` succeeds here where JS throws.
site and both arrays are frozen, as in JS: `strings[0] = "x"` throws a `TypeError`.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
@@ -101,9 +111,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop.
- [x] `for...in` over the enumerable keys of plain objects, arrays, strings, and tool references, following the
prototype chain like JS (`for (k in Object.create({ a: 1 }))` visits `a`; built-in prototype methods are
non-enumerable so `for (k in [])` visits nothing). A key deleted before its turn is skipped and keys added during
the loop are not visited. `null`, `undefined`, and other non-objects iterate nothing. An un-awaited promise
throws rather than iterating.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -122,8 +134,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
call that invoked the built-in, and a resumed `await` starts from depth 0 as in JS, so long async chains such
as recursive pagination are unaffected.
call that invoked the built-in, and built-ins invoking one another count toward the same limit, so a cycle
through built-ins alone (`String(a)` on a 100,000-deep nested array runs `toString` → `join` → `toString`)
bottoms out too. A resumed `await` starts from depth 0 as in JS, so long async chains such as recursive pagination are
unaffected. A thenable that keeps resolving with another thenable loops in constant memory until the timeout, as
in JS.
- [x] Expression and block function bodies.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
@@ -170,8 +185,11 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [ ] `GeneratorFunction.prototype`: every function, generator or not, inherits directly from `Function.prototype`,
and a generator whose `prototype` was replaced by a non-object still creates from the shared generator prototype.
- [x] Generator functions inherit from `GeneratorFunction.prototype` (async ones from
`AsyncGeneratorFunction.prototype`), an ordinary non-callable object under `Function.prototype` whose `prototype`
is the shared generator prototype and vice versa (`Object.getPrototypeOf(g).prototype.constructor`). Neither is
a global; async non-generator functions still inherit from `Function.prototype` directly. A generator whose
`prototype` was replaced by a non-object creates from the shared generator prototype.
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -247,9 +265,13 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
ignore own methods.
- [x] `Error.prototype.toString` converts an object `name` or `message` through its own `toString` (`String(e)` with
`e.message = { toString() { return "m" } }` is `"Error: m"`); an uncaught error's report at the result boundary
still uses the built-in form.
- [x] `Array.prototype.toString` calls `this.join`, so `arr.join = () => "j"` makes `arr + ""`, `String(arr)`, and
`${arr}` all `"j"`; a non-callable `join` gives `"[object Array]"`.
- [ ] ToPrimitive elsewhere: numeric arguments of the Array and Uint8Array methods (`at`, `indexOf` start, `slice`)
still use the built-in form (`NaN`) and ignore own methods.
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
@@ -259,6 +281,8 @@ Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.
## Promises and tools
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
- [x] Tool references have identity: `tools.x === tools.x` and `tools.ns === tools.ns`, so they work as `Map`/`Set`
members and `switch` cases like any other object.
- [x] Direct `await`, repeated awaits, and recursive thenable assimilation when a promise or thenable is returned from
a function/program.
- [x] `Promise.resolve` and `Promise.reject`.
@@ -335,7 +359,7 @@ reject }` object.
`TypeError`.
- [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError`
(`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only
`Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties`
`Object.keys` follow the chain as in JS, and `for...in` enumerates inherited keys too. A second `properties`
argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no
`Object.defineProperty` either).
- [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.16",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+15 -6
View File
@@ -20,7 +20,7 @@ import {
type Value,
} from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { toPrimitiveString } from "./callback.js"
import { toPrimitiveString, withPrimitives } from "./callback.js"
import { formatValue } from "../stdlib/console.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -48,7 +48,9 @@ export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof Throw) {
const value = error.value
if (value instanceof ErrorObj) {
return value.host ? normalizeError(value.host) : { kind: "ExecutionFailure", message: errorToString(value) }
return value.host
? normalizeError(value.host)
: { kind: "ExecutionFailure", message: errorToString(get(value, "name"), get(value, "message")) }
}
let message: string
if (containsRuntimeReference(value)) {
@@ -113,9 +115,7 @@ export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): Value => {
}
/** Error.prototype.toString: `name: message`, omitting whichever side is empty. */
const errorToString = (self: Obj): string => {
const name = get(self, "name")
const message = get(self, "message")
const errorToString = (name: Value, message: Value): string => {
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
@@ -179,7 +179,16 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
})
if (type === "Error") {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
[
"toString",
0,
(thisValue) => {
const self = receiver(Obj, thisValue, "Error.prototype.toString")
return withPrimitives(ctx, "string", [get(self, "name"), get(self, "message")], ([name, message]) =>
errorToString(name, message),
)
},
],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
return ctor
+10 -4
View File
@@ -21,7 +21,7 @@ import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, methods, native, receiver } from "./native.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { checkArrayLength } from "./limits.js"
import { checkArgumentCount } from "./limits.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal } from "./promises.js"
import type { Interpreter } from "./interpreter.js"
@@ -45,7 +45,10 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
return native<R>(ctx.builtins, {
name: `bound ${coerceToString(get(fn, "name"))}`,
length: Math.max(0, fn.length - bound.length),
call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]),
call: (_, rest) => {
checkArgumentCount(bound.length + rest.length)
return ctx.call(fn, args[0], [...bound, ...rest])
},
})
},
],
@@ -61,10 +64,13 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
// CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object.
const listFromArrayLike = (value: Value): Array<Value> => {
if (value === undefined || value === null) return []
if (value instanceof Arr) return [...value.items]
if (value instanceof Arr) {
checkArgumentCount(value.items.length)
return [...value.items]
}
if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.")
const length = Math.max(0, coerceToInteger(get(value, "length")))
checkArrayLength(length)
checkArgumentCount(length)
return Array.from({ length }, (_, index) => get(value, String(index)))
}
@@ -63,7 +63,7 @@ import {
typeError,
unsupportedSyntax,
} from "./model.js"
import { checkStringLength } from "./limits.js"
import { checkArgumentCount, checkArrayLength, checkStringLength } from "./limits.js"
import { locate, materialize } from "./errors.js"
import { type Builtins, primitivePrototype } from "./intrinsics.js"
import { globals } from "./globals.js"
@@ -79,7 +79,8 @@ import {
has,
hidden,
hasPrototype,
keys,
ownKeys,
enumerable,
Native,
parseArrayIndex,
Arguments,
@@ -100,7 +101,7 @@ import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { enumerableSource, restrict } from "../stdlib/object.js"
import { compoundOperators } from "../stdlib/value.js"
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
@@ -318,7 +319,7 @@ export class Interpreter<R> {
readonly logs: Array<string>
/** Template objects by site: a tag sees the same `strings` array every time its literal is evaluated, as in JS. */
readonly templates = new WeakMap<TaggedTemplateExpression, Arr>()
private readonly root: Frame<R>
readonly root: Frame<R>
constructor(options: {
readonly tools: ToolRuntime<R>
@@ -506,7 +507,7 @@ class Frame<R> {
): Fn {
const builtins = this.ctx.builtins
const fn = new Fn(
builtins.Function,
node.generator ? (node.async ? builtins.AsyncGeneratorFunction : builtins.GeneratorFunction) : builtins.Function,
name,
node.params,
node.body,
@@ -955,10 +956,24 @@ class Frame<R> {
}
// for...in over null/undefined iterates nothing, like JS.
// EnumerateObjectProperties: own keys, then each prototype's, visiting a shadowed key once.
private enumerableKeys(value: Value, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)]
if (value === null || value === undefined) return []
return keys(enumerableSource(this.ctx, "for...in", value, node))
const seen = new Set<string>()
const result: Array<string> = []
for (
let current: Obj | null = enumerableSource(this.ctx, "for...in", value, node);
current !== null;
current = current.proto
) {
for (const key of ownKeys(current)) {
if (typeof key !== "string" || seen.has(key)) continue
seen.add(key)
if (enumerable(current, key)) result.push(key)
}
}
return result
}
private evaluateForInStatement(
@@ -982,6 +997,8 @@ class Frame<R> {
const assignment = left.type === "VariableDeclaration" ? undefined : left
for (const key of keys) {
// A key deleted before its turn is skipped, as in JS.
if (right instanceof Obj && !has(right, key)) continue
const result = yield* Effect.gen(function* () {
if (declared?.lexical) {
self.scopes.push()
@@ -1264,6 +1281,7 @@ class Frame<R> {
const next = yield* cursor.next
done = next.done
if (!done) rest.push(next.value)
checkArrayLength(rest.length)
}
yield* consume(element.argument, new Arr(self.ctx.builtins.Array, rest), element)
return
@@ -1710,13 +1728,20 @@ class Frame<R> {
})
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it. A built-in
// reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts. There
// it was invoked by another built-in, and those hops are counted too, so a cycle through built-ins alone
// (toString → join → toString) bottoms out without changing the depth of program calls.
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: this.depth },
)
return Effect.flatMap(CallSite, (site) => {
const natives = this === this.ctx.root ? site.natives + 1 : 0
if (natives > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: Math.max(this.depth, site.depth), natives },
)
})
}
private evaluateCallArguments(
@@ -1734,6 +1759,7 @@ class Frame<R> {
const step = yield* cursor.next
if (step.done) break
args.push(step.value)
checkArgumentCount(args.length)
}
} else {
args.push(yield* self.evaluateExpression(argNode))
@@ -2093,6 +2119,7 @@ class Frame<R> {
const step = yield* cursor.next
if (step.done) break
values.push(step.value)
checkArrayLength(values.length)
}
} else {
values.push(yield* self.evaluateExpression(element))
@@ -2155,12 +2182,16 @@ class Frame<R> {
define(
strings,
"raw",
new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.raw),
restrict(
"freeze",
new Arr(
array,
node.quasi.quasis.map((quasi) => quasi.value.raw),
),
),
frozen,
)
restrict("freeze", strings)
this.ctx.templates.set(node, strings)
return strings
}
@@ -2208,7 +2239,7 @@ class Frame<R> {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
return objectValue.child(key)
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js"
import { Arr, define, ErrorObj, hidden, Native, Obj, readOnly, type Value } from "./objects.js"
export const errorTypes = [
"Error",
@@ -41,6 +41,8 @@ const builtins = [
"AsyncIterator",
"Generator",
"AsyncGenerator",
"GeneratorFunction",
"AsyncGeneratorFunction",
] as const
/**
@@ -79,6 +81,15 @@ export const createBuiltins = (): Builtins => {
}
const iterator = plain()
const asyncIterator = plain()
// %GeneratorFunction.prototype% is an ordinary object linked both ways with %GeneratorPrototype%.
const generatorFunction = (generator: Obj) => {
const proto = new Obj(fn)
define(proto, "prototype", generator, readOnly)
define(generator, "constructor", proto, readOnly)
return proto
}
const generator = new Obj(iterator)
const asyncGenerator = new Obj(asyncIterator)
return {
Object: object,
Function: fn,
@@ -102,8 +113,10 @@ export const createBuiltins = (): Builtins => {
Iterator: iterator,
IteratorHelper: new Obj(iterator),
AsyncIterator: asyncIterator,
Generator: new Obj(iterator),
AsyncGenerator: new Obj(asyncIterator),
Generator: generator,
AsyncGenerator: asyncGenerator,
GeneratorFunction: generatorFunction(generator),
AsyncGeneratorFunction: generatorFunction(asyncGenerator),
Error: error,
TypeError: derived("TypeError"),
RangeError: derived("RangeError"),
@@ -7,6 +7,8 @@ import { rangeError } from "./model.js"
export const MAX_STRING_LENGTH = 1 << 24
/** Longest array a built-in may create or grow to. */
export const MAX_ARRAY_LENGTH = 10_000_000
/** Most arguments one call may receive, including a bound function's bound arguments. */
export const MAX_ARGUMENTS = 250_000
/** Most promises that may be pending at once. */
export const MAX_PENDING_PROMISES = 10_000
/** Deepest nesting a value may have when it crosses to or from the host. */
@@ -16,6 +18,16 @@ export const checkStringLength = (length: number): void => {
if (length > MAX_STRING_LENGTH) throw rangeError("Invalid string length")
}
/** A string the host already built, after checking its length; for outputs at most nine times a capped input. */
export const boundedString = (value: string): string => {
checkStringLength(value.length)
return value
}
export const checkArgumentCount = (count: number): void => {
if (count > MAX_ARGUMENTS) throw rangeError(`Too many arguments: a call may pass at most ${MAX_ARGUMENTS}.`)
}
export const checkArrayLength = (length: number): void => {
if (length > MAX_ARRAY_LENGTH) throw rangeError("Invalid array length")
}
+9 -4
View File
@@ -7,10 +7,15 @@ import type { ErrorObj, Value } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
/** The program call a built-in is running under: where to locate failures born inside it, and how deep the stack is there. */
export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly depth: number }>("codemode/CallSite", {
defaultValue: () => ({ depth: 0 }),
})
/**
* The program call a built-in is running under: where to locate failures born inside it, how deep the stack is there,
* and how many built-ins have invoked one another since program code last called one.
*/
export const CallSite = Context.Reference<{
readonly node?: AstNode
readonly depth: number
readonly natives: number
}>("codemode/CallSite", { defaultValue: () => ({ depth: 0, natives: 0 }) })
export type Binding = {
mutable: boolean
+2 -1
View File
@@ -36,6 +36,7 @@ export const hidden: Attributes = { writable: true, enumerable: false, configura
export const readonly: Attributes = { writable: false, enumerable: false, configurable: true }
/** Constants such as `Math.PI` and a constructor's `prototype`. */
export const frozen: Attributes = { writable: false, enumerable: false, configurable: false }
export const readOnly: Attributes = { writable: false, enumerable: false, configurable: true }
/**
* An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a
@@ -645,7 +646,7 @@ export const ownKeys = (target: Obj): Array<string | symbol> => {
]
}
const enumerable = (target: Obj, key: string | symbol): boolean => own(target, key)?.enumerable === true
export const enumerable = (target: Obj, key: string | symbol): boolean => own(target, key)?.enumerable === true
/** Own enumerable keys, including the iterator symbols; what spread and `Object.assign` copy. */
export const enumerableKeys = (target: Obj): Array<string | symbol> =>
+32 -7
View File
@@ -16,12 +16,20 @@ const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: Value)
return undefined
})
// Unhandled rejections past the first hundred are only counted, and each reported message is cut, so a rejection loop
// cannot grow the report without bound.
const MAX_REJECTION_DIAGNOSTICS = 100
const MAX_DIAGNOSTIC_LENGTH = 4096
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class Pending<R> {
private readonly active = new Set<PromiseObj>()
private readonly ids = new WeakMap<PromiseObj, number>()
private readonly observed = new WeakSet<PromiseObj>()
private readonly failures = new Map<number, Diagnostic>()
// An unreachable dropped promise can never be handled later, so a weak set is enough to keep the count exact.
private readonly dropped = new WeakSet<PromiseObj>()
private droppedCount = 0
private nextID = 0
constructor(
@@ -60,11 +68,17 @@ export class Pending<R> {
this.ids.delete(promise)
return
}
if (this.failures.size >= MAX_REJECTION_DIAGNOSTICS) {
this.dropped.add(promise)
this.droppedCount += 1
return
}
const failure = normalizeError(Cause.squash(exit.cause))
this.failures.set(id, {
...failure,
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
})
const reason = failure.message.slice(0, MAX_DIAGNOSTIC_LENGTH)
// A JSC slice shares the memory of the string it cuts; slicing the short joined string copies it, so the long
// original message is freed.
const message = `Unhandled rejection from an un-awaited promise: ${reason}`.slice(0, MAX_DIAGNOSTIC_LENGTH)
this.failures.set(id, { ...failure, message })
})
return promise
})
@@ -74,6 +88,7 @@ export class Pending<R> {
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: PromiseObj): void {
this.observed.add(promise)
if (this.dropped.delete(promise)) this.droppedCount -= 1
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
@@ -88,7 +103,15 @@ export class Pending<R> {
}
diagnostics(): Array<Diagnostic> {
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
const retained = [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
if (this.droppedCount === 0) return retained
return [
...retained,
{
kind: "ExecutionFailure",
message: `Unhandled rejections from un-awaited promises not reported individually: ${this.droppedCount}.`,
},
]
}
// Re-check because a straggler can create promises before its interruption lands.
@@ -116,6 +139,8 @@ export const resolvePromiseValue = <R>(
const then = get(value, "then")
if (typeofValue(then) !== "function") return Effect.succeed(value)
// The next thenable resolves through a tail flatMap, not a nested yield*, so a chain that never ends runs in
// constant memory, as in JS.
return Effect.gen(function* () {
// Promise resolution invokes a thenable's method in a later job.
yield* Effect.yieldNow
@@ -127,8 +152,8 @@ export const resolvePromiseValue = <R>(
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return yield* resolvePromiseValue(ctx, yield* Deferred.await(deferred), own)
})
return yield* Deferred.await(deferred)
}).pipe(Effect.flatMap((next) => resolvePromiseValue(ctx, next, own)))
}
export const resolvePromise = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<PromiseObj, never, R> => {
+8 -7
View File
@@ -5,6 +5,7 @@ import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpret
import {
define,
get,
Callable,
hidden,
Arr,
GeneratorObj,
@@ -63,6 +64,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
? step.value
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
)
checkArrayLength(values.length)
index += 1
}
})
@@ -165,13 +167,12 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
[
"toString",
0,
(thisValue) =>
withPrimitives(
ctx,
"string",
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
(items) => items.map(coerceToString).join(","),
),
(thisValue) => {
// Spec: delegate to this.join, so an overridden join shows up in `arr + ""` and String(arr).
const target = self(thisValue, "toString")
const join = get(target, "join")
return join instanceof Callable ? ctx.call(join, target, []) : `[object ${target.tag}]`
},
],
[
"includes",
+11 -3
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { boundedString, checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
import {
@@ -171,9 +171,17 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
return joined
},
],
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
["toString", 0, (thisValue) => boundedString(self(thisValue, "toString").bytes.join(","))],
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
[
"toHex",
0,
(thisValue) => {
const bytes = self(thisValue, "toHex").bytes
checkStringLength(bytes.length * 2)
return bytes.toHex()
},
],
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").bytes.keys())],
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").bytes.values())],
[
+1 -1
View File
@@ -104,7 +104,7 @@ const propertyKey = (value: Value): PropertyKey =>
// SetIntegrityLevel: primitives pass through. A typed array's bytes cannot carry attributes, so JS throws after
// already making it non-extensible.
const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
export const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
if (!(value instanceof Obj)) return value
value.extensible = false
if (level === "preventExtensions") return value
+45 -3
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Impl, type Method, methods } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { checkArrayLength, checkStringLength, MAX_STRING_LENGTH } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import {
define,
@@ -36,6 +36,45 @@ const requireDataArgument = (name: string, index: number, arg: Value): Value =>
return arg
}
// The host builds a replaced string in one synchronous step, so bound its length first: the unmatched text plus, per
// match, the replacement, a whole subject per `` $` `` or `$'`, and the match's longest group per `$` token. Groups lie
// inside their match, so they sum to at most the subject, unless a positive lookaround captures past it. Matches are
// only measured when the bound for "every position matches" is too big; the measured bound is exact without `$`.
const checkReplacementLength = (subject: string, search: RegExp | string, replacement: string, all: boolean): void => {
const tokens = (token: string) => countOccurrences(replacement, token)
const perMatch = replacement.length + (tokens("$`") + tokens("$'")) * subject.length
const bound = (matches: { count: number; matched: number; reach: number }) =>
subject.length - matches.matched + matches.count * perMatch + tokens("$") * matches.reach
const lookaround = typeof search !== "string" && /\(\?<?=/.test(search.source)
const reach = lookaround ? (subject.length + 1) * subject.length : subject.length
if (bound({ count: subject.length + 1, matched: 0, reach }) <= MAX_STRING_LENGTH) return
if (!all) return checkStringLength(bound({ count: 1, matched: 0, reach: subject.length }))
if (typeof search === "string") {
const count = countOccurrences(subject, search)
return checkStringLength(bound({ count, matched: count * search.length, reach: count * search.length }))
}
// matchAll steps past an empty match by code point under `u` and `v`; the copy starts at 0 whatever the program's
// `lastIndex` is, as a global `replace` does.
const matches = subject.matchAll(new RegExp(search)).reduce(
(total, match) => ({
count: total.count + 1,
matched: total.matched + match[0].length,
reach: total.reach + match.reduce((longest, group) => Math.max(longest, group?.length ?? 0), 0),
}),
{ count: 0, matched: 0, reach: 0 },
)
checkStringLength(bound(matches))
}
const countOccurrences = (subject: string, needle: string): number => {
if (needle === "") return subject.length + 1
let count = 0
for (let index = subject.indexOf(needle); index !== -1; index = subject.indexOf(needle, index + needle.length)) {
count += 1
}
return count
}
const replaceAllNeedsGlobal = (pattern: RegExp) => {
if (!pattern.global) {
throw typeError(
@@ -208,10 +247,13 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
const regex = pattern.regex
const text = str(name, primitives, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
checkReplacementLength(value, regex, text, regex.global)
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
}
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
const needle = str(name, primitives, 0)
const text = str(name, primitives, 1)
checkReplacementLength(value, needle, text, name === "replaceAll")
return name === "replace" ? value.replace(needle, text) : value.replaceAll(needle, text)
},
)
})
+15 -3
View File
@@ -1,4 +1,5 @@
import { Effect } from "effect"
import { boundedString, checkStringLength } from "../interpreter/limits.js"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js"
import {
@@ -46,9 +47,10 @@ export const uriGlobal = <R>(ctx: Interpreter<R>, name: UriFunction) =>
fn<R>(ctx.builtins, name, 1, (_, args) => {
const value = coerceToString(args[0])
try {
return uriFunctions[name](value)
return boundedString(uriFunctions[name](value))
} catch (error) {
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
if (!(error instanceof URIError)) throw error
throw uriError(`${name} received malformed URI data: ${error.message}`)
}
})
@@ -272,7 +274,17 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").params.keys())],
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").params.values())],
["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
"toString",
0,
(thisValue) => {
const params = self(thisValue, "toString").params
// Every character serializes to at least one, plus `=` per pair and `&` between pairs, so this lower bound
// rejects before the host builds anything.
checkStringLength([...params].reduce((length, [key, value]) => length + key.length + value.length + 2, -1))
return boundedString(params.toString())
},
],
[
"forEach",
1,
+2
View File
@@ -1,4 +1,5 @@
import { fn, methods } from "../interpreter/native.js"
import { checkStringLength } from "../interpreter/limits.js"
import { typeError } from "../interpreter/model.js"
import {
define,
@@ -32,6 +33,7 @@ export const base64Global = <R>(ctx: Interpreter<R>, name: "atob" | "btoa") =>
fn<R>(ctx.builtins, name, 1, (_, args) => {
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
const input = coerceToString(args[0])
if (name === "btoa") checkStringLength(Math.ceil(input.length / 3) * 4)
try {
return name === "atob" ? atob(input) : btoa(input)
} catch {
+9
View File
@@ -113,7 +113,16 @@ export const toolExpression = (path: string) =>
.join("")
export class ToolReference {
private readonly children = new Map<string, ToolReference>()
constructor(readonly path: ReadonlyArray<string>) {}
/** One reference per path, so `tools.a === tools.a` holds like any other member read. */
child(key: string): ToolReference {
const existing = this.children.get(key)
if (existing !== undefined) return existing
const created = new ToolReference([...this.path, key])
this.children.set(key, created)
return created
}
}
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
+19
View File
@@ -178,6 +178,25 @@ describe("call depth", () => {
expect(Date.now() - started).toBeLessThan(2000)
})
test("recursion routed through nested built-ins keeps counting depth", async () => {
const started = Date.now()
expect(
await value(`
const a = [1]
a.join = () => a + ""
const o = { toString() { return [o].map(String)[0] } }
const e = new Error()
e.message = { toString() { return String(e) } }
const names = []
for (const run of [() => String(a), () => String(o), () => String(e)]) {
try { run() } catch (error) { names.push(error.name) }
}
return names
`),
).toEqual(["RangeError", "RangeError", "RangeError"])
expect(Date.now() - started).toBeLessThan(2000)
})
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
+233 -3
View File
@@ -1382,7 +1382,7 @@ describe("Object.getPrototypeOf and Object.create", () => {
expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol")
})
test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => {
test("Object.create links the prototype: inherited reads, in, own-only keys, and for...in", async () => {
expect(
await value(`
const p = { greet(name) { return "hi " + name }, a: 1 }
@@ -1392,7 +1392,7 @@ describe("Object.getPrototypeOf and Object.create", () => {
for (const key in c) seen.push(key)
return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen]
`),
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]])
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name", "greet", "a"]])
})
test("Object.create rejects non-object prototypes and property descriptors", async () => {
@@ -1560,7 +1560,7 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
"Function.prototype.call called on incompatible receiver",
)
expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list")
expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Invalid array length")
expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Too many arguments")
expect(
await value(
`function f() { return arguments.length } return [f.apply(null, { length: -5 }), f.apply(null, { length: "2" })]`,
@@ -1984,3 +1984,233 @@ describe("WeakMap and WeakSet", () => {
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
})
})
describe("small language leftovers", () => {
test("for...in walks the prototype chain and skips keys deleted before their turn", async () => {
expect(
await value(`
const o = Object.create({ a: 1, shadowed: 1 })
o.b = 2
o.shadowed = 3
const keys = []
for (const k in o) keys.push(k)
const live = { a: 1, b: 2, c: 3 }
const seen = []
for (const k in live) { seen.push(k); delete live.b; live.z = 1 }
const none = []
for (const k in []) none.push(k)
for (const k in new TypeError("x")) none.push(k)
return [keys, seen, none]
`),
).toEqual([["b", "shadowed", "a"], ["a", "c"], []])
})
test("tagged template objects are frozen", async () => {
expect(
await value(`
const tag = (s) => s
const f = () => tag\`a\${1}b\`
return [f() === f(), Object.isFrozen(f()), Object.isFrozen(f().raw)]
`),
).toEqual([true, true, true])
expect((await error("const tag = (s) => s; tag`a`[0] = 'x'")).message).toContain("read only")
})
test("Array.prototype.toString delegates to join", async () => {
expect(
await value(`
const a = [1, 2]
a.join = () => "j"
const b = [1]
b.join = 5
return [a + "", String(a), \`\${a}\`, b.toString(), Array.prototype.toString.call([3, [4]])]
`),
).toEqual(["j", "j", "j", "[object Array]", "3,4"])
})
test("Error.prototype.toString converts object name and message", async () => {
expect(
await value(`
const e = new Error("m")
e.message = { toString() { return "obj" } }
e.name = { valueOf() { return "N" }, toString() { return "T" } }
return [String(e), Error.prototype.toString.call({ name: "", message: "m" }), Error.prototype.toString.call({})]
`),
).toEqual(["T: obj", "m", "Error"])
expect(
(await error(`const e = new Error(); e.message = { toString() { throw new RangeError("r") } }; String(e)`))
.message,
).toContain("r")
})
test("generator functions inherit from GeneratorFunction.prototype", async () => {
expect(
await value(`
function* g() {}
async function* ag() {}
const GFP = Object.getPrototypeOf(g)
g.prototype = null
return [
typeof GFP, GFP === Function.prototype, Object.getPrototypeOf(GFP) === Function.prototype,
GFP.prototype.constructor === GFP, Object.getPrototypeOf(ag) === GFP, typeof g.bind,
Object.getPrototypeOf(g()) === GFP.prototype,
]
`),
).toEqual(["object", false, true, true, false, "function", true])
expect((await error(`function* g() {} Object.getPrototypeOf(g)()`)).message).toContain("not a function")
})
})
describe("confinement caps", () => {
test("string replacement results are bounded before the host builds them", async () => {
expect((await error(`"x".repeat(2 ** 16).replaceAll("x", "x".repeat(2 ** 9))`)).message).toContain(
"Invalid string length",
)
expect((await error(`"x".repeat(2 ** 20).replace(/x/g, "yyyyyyyyyyyyyyyyyyyyyyyyy")`)).message).toContain(
"Invalid string length",
)
// Every prefix token can repeat the subject once per match, and so can a capture inside a lookahead.
expect((await error('"x".repeat(2600).replace(/x/g, "$`".repeat(100))')).message).toContain("Invalid string length")
expect((await error('"x".repeat(20000).replace(/(?=(x*))/g, "$1")')).message).toContain("Invalid string length")
expect(
(await error(`const p = new URLSearchParams(); const s = "x".repeat(2 ** 24); p.append(s, s); p.toString()`))
.message,
).toContain("Invalid string length")
// Counting empty matches must step over surrogate pairs under the u flag.
expect(
await value(
`return ["😀".repeat(10).replace(/(?:)/gu, "x".repeat(2 ** 20)).length, "1234567".replace(/(\\d)(?=(\\d{3})+$)/g, "$1,")]`,
),
).toEqual([11534356, "1,234,567"])
expect(
await value(`
return [
"x".repeat(2 ** 23).replaceAll("x", "y").length,
"ab".repeat(2 ** 21).replace(/a/g, "$&").length,
"abc".replaceAll("", "-"),
"aaa".replace(/a/g, (m) => m + m),
"hello world ".repeat(2000).replace(/(\\w+)(?=\\s)/g, "[$1]").length,
"x".repeat(100).replaceAll("x", "y".repeat(167772)).length,
"a ".repeat(3e6).replace(/ +/g, " ").length,
]
`),
).toEqual([8388608, 4194304, "-a-b-c-", "aaaaaa", 32000, 16777200, 6000000])
expect((await error(`encodeURIComponent("\\u{1F600}".repeat(2 ** 22))`)).message).toContain("Invalid string length")
expect((await error(`btoa("x".repeat(2 ** 24))`)).message).toContain("Invalid string length")
expect((await error(`new Uint8Array(9e6).toHex()`)).message).toContain("Invalid string length")
expect((await error(`new Uint8Array(9e6).toString()`)).message).toContain("Invalid string length")
expect(
(await error(`const p = new URLSearchParams(); p.append("\\u20ac".repeat(2 ** 21), ""); p.toString()`)).message,
).toContain("Invalid string length")
expect((await error(`decodeURIComponent("%")`)).message).toContain("malformed URI")
})
test("recursion through built-ins alone hits the call depth limit", async () => {
expect(
await value(`
let a = []
for (let i = 0; i < 100000; i++) a = [a]
let shallow = []
for (let i = 0; i < 3000; i++) shallow = [shallow]
const names = []
try { String(a) } catch (e) { names.push(e.name) }
try { \`\${a}\` } catch (e) { names.push(e.name) }
const b = [1]
b.join = b.toString
try { String(b) } catch (e) { names.push(e.name) }
return [names, String(shallow).length, [[[1]]].map((x) => String(x))]
`),
).toEqual([["RangeError", "RangeError", "RangeError"], 0, ["1"]])
// A resumed await starts from depth 0 even when each step recurses through a bound callback of a built-in.
expect(
await value(`
let n = 0
let failure = null
async function step() {
await null
n++
if (n < 12000) [0].forEach((() => { step().catch((e) => { failure = e.name }) }).bind(null))
}
await step()
while (n < 12000 && failure === null) await null
return [n, failure]
`),
).toEqual([12000, null])
})
test("argument and spread counts are capped", async () => {
expect((await error(`Math.max(...Array(300000).fill(1))`)).message).toContain("Too many arguments")
expect((await error(`Math.max.apply(null, { length: 1e9 })`)).message).toContain("Too many arguments")
expect(
(await error(`function f() { return arguments.length } const a = Array(2e5).fill(0); f(...a, ...a)`)).message,
).toContain("Too many arguments")
expect((await error(`function f() {} f.apply(null, Array(300000).fill(0))`)).message).toContain(
"Too many arguments",
)
expect(
(
await error(
`const a = Array(2e5).fill(0); const f = ((...xs) => xs.length).bind(null, ...a).bind(null, ...a); f()`,
)
).message,
).toContain("Too many arguments")
expect((await error(`const a = Array(6e6).fill(0); [...a, ...a]`)).message).toContain("Invalid array length")
expect(await value(`return Math.max(...Array(200000).fill(7))`)).toBe(7)
})
test("a thenable chain resolves iteratively and a self-resolving promise is a chaining cycle", async () => {
expect(
await value(
`const mk = (n) => n === 0 ? "done" : { then(res) { res(mk(n - 1)) } }; return await Promise.resolve(mk(5000))`,
),
).toBe("done")
expect(
(await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r(p))); await p`)).message,
).toContain("Chaining cycle")
// The cycle check still applies after a thenable step hands back the promise being resolved.
expect(
(await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r({ then(r2) { r2(p) } }))); await p`))
.message,
).toContain("Chaining cycle")
})
test("a thenable that resolves with itself runs in constant memory until the timeout", async () => {
Bun.gc(true)
const before = process.memoryUsage().heapUsed
let peak = before
const sample = setInterval(() => (peak = Math.max(peak, process.memoryUsage().heapUsed)), 50)
const result = await Effect.runPromise(
CodeMode.execute({
code: `const t = { then(res) { res(t) } }; await Promise.resolve(t)`,
tools: {},
limits: { timeoutMs: 1000 },
}),
)
clearInterval(sample)
expect(result.ok ? undefined : result.error.kind).toBe("TimeoutExceeded")
expect(peak - before).toBeLessThan(50_000_000)
})
test("unhandled rejection diagnostics are capped with a summary", async () => {
const result = await run(`for (let i = 0; i < 500; i++) Promise.reject(new Error("x".repeat(10000))); return 1`)
if (!result.ok) throw new Error(result.error.message)
expect(result.warnings?.length).toBe(101)
expect(result.warnings?.[0]?.message.length).toBeLessThanOrEqual(4096)
expect(result.warnings?.at(-1)?.message).toContain("not reported individually: 400")
const handled = await run(
`const ps = []; for (let i = 0; i < 200; i++) ps.push(Promise.reject(new Error("x"))); await Promise.allSettled(ps); return 1`,
)
expect(handled.ok && handled.warnings).toBeUndefined()
})
test("cut rejection messages do not keep the full message alive", async () => {
Bun.gc(true)
const before = process.memoryUsage().heapUsed
const result = await run(
`for (let i = 0; i < 50; i++) Promise.reject(new Error(i + "x".repeat(2 ** 22))); return 1`,
)
Bun.gc(true)
expect(result.ok && result.warnings?.length).toBe(50)
expect(process.memoryUsage().heapUsed - before).toBeLessThan(50_000_000)
})
})
@@ -884,7 +884,6 @@ built-ins/AsyncGeneratorFunction/invoked-as-constructor-no-arguments.js # TypeE
built-ins/AsyncGeneratorFunction/invoked-as-function-multiple-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/invoked-as-function-no-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/invoked-as-function-single-argument.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/AsyncGeneratorFunction/prototype/not-callable.js # Expected SameValue(«"function"», «"object"») to be true
built-ins/AsyncGeneratorPrototype/next/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
built-ins/AsyncGeneratorPrototype/return/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
built-ins/AsyncGeneratorPrototype/throw/this-val-not-async-generator.js # TypeError: Cannot read properties of undefined (reading '…').
@@ -900,7 +899,6 @@ built-ins/Date/prototype/toJSON/invoke-result.js # TypeError: Date.prototype.to
built-ins/Date/prototype/toJSON/to-primitive-value-of.js # TypeError: Date.prototype.toJSON called on incompatible receiver a data object.
built-ins/Date/prototype/toString/format.js # Expected SameValue(«null», «null») to be false
built-ins/Date/prototype/toString/negative-year.js # Date.prototype.toString serializes year -1 to "-0001" Expected SameValue(«undefined», «"-0001"») to
built-ins/Error/prototype/toString/tostring-message-throws-toprimitive.js # ToPrimitive(msg) called by ToString(msg) throws TypeError Expected a TypeError to be thrown but no e
built-ins/Function/15.3.5-1gs.js # Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Function/15.3.5-2gs.js # Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Function/15.3.5.4_2-1gs.js # Expected a TypeError to be thrown but no exception was thrown at all
@@ -931,7 +929,6 @@ built-ins/GeneratorFunction/invoked-as-constructor-no-arguments.js # TypeError:
built-ins/GeneratorFunction/invoked-as-function-multiple-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/invoked-as-function-no-arguments.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/invoked-as-function-single-argument.js # TypeError: The Function constructor is not supported; write the function inline.
built-ins/GeneratorFunction/prototype/not-callable.js # Expected SameValue(«"function"», «"object"») to be true
built-ins/GeneratorPrototype/throw/from-state-completed.js # Expected a E but got a TypeError
built-ins/GeneratorPrototype/throw/from-state-suspended-start.js # Expected a E but got a TypeError
built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function.
@@ -1468,12 +1465,10 @@ language/expressions/function/dstr/ary-init-iter-get-err-array-prototype.js # E
language/expressions/function/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/function/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/default-proto.js # Expected SameValue(«object», «undefined») to be true
language/expressions/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/generators/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/generators/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/expressions/generators/prototype-relation-to-function.js # Expected SameValue(«object», «») to be true
language/expressions/greater-than-or-equal/S11.8.4_A3.2_T1.2.js # TypeError: Binary operators require data values.
language/expressions/greater-than/S11.8.2_A3.2_T1.2.js # TypeError: Binary operators require data values.
language/expressions/in/S8.12.6_A2_T2.js # TypeError: Robin cannot be constructed: user-defined constructors and classes are not supported. Cal
@@ -1559,7 +1554,6 @@ language/expressions/super/prop-expr-obj-key-err.js # unsupported syntax Super
language/expressions/super/prop-expr-obj-ref-strict.js # unsupported syntax Super
language/expressions/super/prop-expr-obj-unresolvable.js # Expected SameValue(«SyntaxError», «ReferenceError») to be true
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
language/expressions/this/11.1.1-1.js # Expected SameValue(«undefined», «undefined») to be false
language/expressions/unary-minus/S11.4.7_A3_T5.js # TypeError: Unary operators require data values.
language/expressions/unary-plus/S11.4.6_A3_T5.js # TypeError: Unary operators require data values.
@@ -1588,7 +1582,6 @@ language/statements/async-generator/dstr/dflt-ary-ptrn-elem-id-iter-val-array-pr
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
language/statements/const/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/const/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/for-in/S12.6.4_A7_T2.js # for...in visits keys deleted during the loop
language/statements/for-in/order-enumerable-shadowed.js # Object.create property descriptors are not supported; assign the fields after creating the object.
language/statements/for-in/S12.6.4_A6.1.js # TypeError: FACTORY cannot be constructed: user-defined constructors and classes are not supported. C
language/statements/for-in/S12.6.4_A6.js # TypeError: FACTORY cannot be constructed: user-defined constructors and classes are not supported. C
@@ -1660,12 +1653,10 @@ language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Ex
language/statements/function/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/default-proto.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/dflt-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
language/statements/generators/prototype-relation-to-function.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/restricted-properties.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
+13
View File
@@ -368,3 +368,16 @@ describe("tool references under ==", () => {
).toEqual([false, false, false, 0])
})
})
describe("tool reference identity", () => {
test("repeated member reads yield the same reference", async () => {
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok"), "ns.inner": echo("Inner", "in") } })
expect(
await value(
runtime,
`return [tools.probe === tools.probe, tools.ns.inner === tools.ns.inner, tools.ns === tools.ns, tools["probe"] === tools.probe,
tools.probe === tools.ns.inner, new Set([tools.probe, tools.probe]).size, await tools.ns.inner({})]`,
),
).toEqual([true, true, true, true, false, 1, "in"])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
-2
View File
@@ -197,8 +197,6 @@ export const provider = (options: Options): OAuthClientProvider => {
saveTokens: (tokens) => options.store.saveTokens(tokens),
redirectToAuthorization: (url) => {
if (!redirect) throw refuse("user authorization")
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error(`MCP server "${options.config.url}" returned a ${url.protocol} authorization URL; only http and https are supported`)
return redirect.open(url)
},
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { IntegrationConnection } from "../../integration/connection.js"
import { Credential } from "../../credential.js"
@@ -8,7 +8,6 @@ import { CopilotModels } from "../../github-copilot/models.js"
import { App } from "../../app.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -163,7 +162,6 @@ export const GithubCopilotPlugin = define({
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
token?: string
models?: CopilotModels.Snapshot
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
@@ -175,7 +173,6 @@ export const GithubCopilotPlugin = define({
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
loaded.token = undefined
loaded.models = undefined
loaded.connection = undefined
return
@@ -203,7 +200,6 @@ export const GithubCopilotPlugin = define({
)
return
loaded.baseURL = url
loaded.token = credential.refresh
loaded.models = remote
loaded.connection = connection
})
@@ -277,29 +273,6 @@ export const GithubCopilotPlugin = define({
}),
{ providerID: Provider.ID.githubCopilot },
)
// GitHub's integration guide designates session naming as a utility scenario served by
// free, rate-limited utility models. Any failure leaves the result unset so the normal
// billable title path still runs.
yield* ctx.session.hook(
"title",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (!loaded.baseURL || !loaded.token || !loaded.models) return
const agent = yield* ctx.agent.get({ agentID: Agent.ID.make("title") }).pipe(Effect.orElseSucceed(() => undefined))
if (agent?.data.model) return
const model = utilityTitleModels.find((id) => loaded.models?.has(id))
if (!model) return
evt.result = yield* utilityTitle(
{ baseURL: loaded.baseURL, token: loaded.token, model, app: ctx.app },
evt,
).pipe(
Effect.tapError((cause) => Effect.logDebug("Copilot utility title failed", { model, cause })),
Effect.orElseSucceed(() => undefined),
)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
@@ -396,70 +369,6 @@ function request(url: string, init: RequestInit) {
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
// Matches the Copilot client: gpt-4o-mini is the "small utility" model; when an account
// lacks it the request falls through to the regular title path instead of another guess.
export const utilityTitleModels = ["gpt-4o-mini"]
const UtilityCompletion = Schema.Struct({
choices: Schema.Array(Schema.Struct({ message: Schema.Struct({ content: Schema.NullOr(Schema.String) }) })),
})
const decodeUtilityCompletion = Schema.decodeUnknownEffect(Schema.fromJsonString(UtilityCompletion))
// This issues its own /chat/completions call instead of steering core's title request.
// The `session.title` hook exposes `model` read-only, so pointing core at another model
// would mean widening that contract and re-resolving inside SessionModelRequest; rewriting
// the body in `http.request` is worse still, since the endpoint (/responses for GPT-5,
// /v1/messages for Claude) is fixed before that hook runs and gpt-4o-mini only serves
// /chat/completions. Utility titles are Copilot-specific, stateless, non-streaming, and
// free, so a small request here costs less than a core seam. Trade-offs: no session
// usage record for the title step (the call bills nothing) and no `http.*` hook visibility.
export function utilityTitle(
input: { baseURL: string; token: string; model: string; app: App.Info; fetch?: Fetch },
request: Pick<SessionTitle, "sessionID" | "system" | "messages" | "options">,
) {
const send = input.fetch ?? fetch
const text = (parts: ReadonlyArray<{ type: string; text?: string | null }>) =>
parts.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])).join("\n")
const messages = [
...(request.system.length ? [{ role: "system", content: text(request.system) }] : []),
...request.messages.map((message) => ({ role: message.role, content: text(message.content) })),
]
return Effect.tryPromise({
try: async (signal) => {
const response = await send(`${input.baseURL}/chat/completions`, {
method: "POST",
signal,
headers: {
Authorization: `Bearer ${input.token}`,
"Content-Type": "application/json",
"User-Agent": App.useragent(input.app),
"X-GitHub-Api-Version": apiVersion,
"Openai-Intent": "conversation-edits",
"X-Interaction-Type": "agent-session-name-generation",
"X-Interaction-Id": request.sessionID,
"x-initiator": "agent",
},
body: JSON.stringify({
model: input.model,
messages,
stream: false,
...(typeof request.options.maxTokens === "number" ? { max_tokens: request.options.maxTokens } : {}),
}),
})
if (!response.ok) throw new Error(`Utility title request failed: ${response.status}`)
return response.text()
},
catch: (cause) => cause,
}).pipe(
Effect.timeout("20 seconds"),
Effect.flatMap(decodeUtilityCompletion),
Effect.flatMap((completion) => {
const title = completion.choices[0]?.message.content?.trim()
return title ? Effect.succeed(title) : Effect.fail(new Error("Utility title response was empty"))
}),
)
}
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
const send = upstream ?? fetch
return async (input, init) => {
+3 -29
View File
@@ -2,7 +2,7 @@ export * as SessionProviderContext from "./provider-context.js"
import { Message } from "@opencode/ai"
import { SessionProviderContext } from "@opencode/schema/session-provider-context"
import { Predicate, Schema } from "effect"
import { Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { Hash } from "@opencode/util/hash"
import type { SessionMessage } from "./message.js"
@@ -54,31 +54,5 @@ export const encode = (provenance: Provenance, replacement: ReadonlyArray<Messag
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(JSON.stringify(replacement)),
})
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(upgradeLegacyMedia(context.messages))
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(upgradeLegacyMedia(context.messages))
/**
* Before 2.0.15, version-1 checkpoints stored mediaType/data directly on media parts. Core only built
* those parts from prompt attachments, whose data the Prompt schema guarantees is base64.
*/
function upgradeLegacyMedia(input: Info["messages"]) {
if (!Array.isArray(input)) return input
return input.map((message: unknown) => {
if (!Predicate.isObject(message) || !Array.isArray(message.content)) return message
return {
...message,
content: message.content.map((part: unknown) => {
if (
!Predicate.isObject(part) ||
part.type !== "media" ||
part.media !== undefined ||
typeof part.mediaType !== "string" ||
typeof part.data !== "string"
)
return part
const { mediaType, data, ...rest } = part
return { ...rest, media: { source: { type: "base64", data, mediaType } } }
}),
}
})
}
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
+11 -2
View File
@@ -54,7 +54,8 @@ const modelID = (model: Model.Info) => model.modelID ?? model.id
function claudeInfo(model: Model.Info) {
const id = modelID(model)
const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[.-](\d+))?/i.exec(id)
// Versions are at most two digits so snapshot dates such as `-20260901` are not read as versions.
const familyFirst = /(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:[.-](\d{1,2}))?(?!\d)/i.exec(id)
const versionFirst = /claude-(\d+)(?:[.-](\d+))?-(opus|sonnet|haiku|fable|mythos)/i.exec(id)
const family = (familyFirst?.[1] ?? versionFirst?.[3])?.toLowerCase()
const major = Number(familyFirst?.[2] ?? versionFirst?.[1])
@@ -64,7 +65,12 @@ function claudeInfo(model: Model.Info) {
major,
minor,
manual: (major === 3 && minor === 7) || (major === 4 && minor < 6),
always: family === "fable" || family === "mythos" || id.toLowerCase().includes("mythos-preview"),
// Opus 5.5 and later reject disabled thinking.
always:
family === "fable" ||
family === "mythos" ||
id.toLowerCase().includes("mythos-preview") ||
(family === "opus" && (major > 5 || (major === 5 && minor >= 5))),
}
}
@@ -390,6 +396,7 @@ const bedrockConverse: Protocol = (model, support) => {
return fields({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } })
})
case "toggle":
if (claude && claudeInfo(model).always) return []
return claude
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
@@ -449,6 +456,7 @@ const bedrockAISDK: Protocol = (model, support) => {
: { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } },
}))
case "toggle":
if (claude && claudeInfo(model).always) return []
return claude
? toggle(
{ settings: { additionalModelRequestFields: { thinking: { type: "disabled" } } } },
@@ -507,6 +515,7 @@ const sapAICore: Protocol = (model, support) => {
sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }),
sap({ additionalModelRequestFields: { thinking: { type: "enabled" } } }),
)
if (id.includes("anthropic") && claudeInfo(model).always) return []
if (id.includes("anthropic"))
return toggle(
sap({ additionalModelRequestFields: { thinking: { type: "disabled" } } }),
@@ -1,22 +0,0 @@
[
{
"role": "user",
"content": [
{ "type": "text", "text": "retained attachments" },
{
"type": "media",
"mediaType": "image/png",
"data": "AQID",
"filename": "attachment.png",
"cache": { "type": "ephemeral" },
"metadata": { "attachment": "original" },
"providerMetadata": { "openai": { "detail": "high" } }
},
{ "type": "media", "mediaType": "image/jpeg", "data": "BAUG" }
]
},
{
"role": "assistant",
"content": [{ "type": "compaction", "provider": "openai", "encrypted": "opaque-checkpoint", "id": "cp_legacy" }]
}
]
-8
View File
@@ -291,14 +291,6 @@ describe("MCP OAuth", () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
test("rejects an authorization endpoint that is not http or https", async () => {
const { server } = authorizationServer({ authorization_endpoint: "file:///tmp/authorize" })
await expect(Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))).rejects.toThrow(
"returned a file: authorization URL",
)
})
test("sends the configured URL as the resource when the server publishes no metadata", async () => {
const { server, tokenRequests } = authorizationServer({})
const url = `${server.url.origin}/mcp`
@@ -15,9 +15,7 @@ import {
copilotEntitlementError,
copilotFetch,
GithubCopilotPlugin,
utilityTitle,
} from "@opencode/core/plugin/provider/github-copilot"
import { Message, SystemPart } from "@opencode/ai"
import { Provider } from "@opencode/core/provider"
import { Integration } from "@opencode/core/integration"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
@@ -239,73 +237,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
const titleRequest = {
sessionID: Session.ID.make("ses_title"),
system: [SystemPart.make("You are a title generator.")],
messages: [Message.user("how do I make my python script faster")],
options: { maxTokens: 32 },
}
const utilityInput = (send: (init?: RequestInit) => Response | Promise<Response>) => ({
baseURL: "https://api.individual.githubcopilot.com",
token: "token",
model: "gpt-4o-mini",
app: App.make({ name: "test", version: "1.2.3", channel: "beta" }),
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => send(init),
})
it.live("names sessions with the utility model as a free background request", () =>
Effect.gen(function* () {
const seen: Array<{ url?: string; init?: RequestInit }> = []
const title = yield* utilityTitle(
{
...utilityInput(() => Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })),
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
seen.push({ url: String(input), init })
return Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })
},
},
titleRequest,
)
expect(title).toBe("Speed Up Python Script")
expect(seen[0]?.url).toBe("https://api.individual.githubcopilot.com/chat/completions")
const headers = new Headers(seen[0]?.init?.headers)
expect(headers.get("authorization")).toBe("Bearer token")
expect(headers.get("x-interaction-type")).toBe("agent-session-name-generation")
expect(headers.get("x-interaction-id")).toBe("ses_title")
expect(headers.get("x-initiator")).toBe("agent")
expect(headers.get("x-github-api-version")).toBe("2026-08-01")
expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
model: "gpt-4o-mini",
stream: false,
max_tokens: 32,
messages: [
{ role: "system", content: "You are a title generator." },
{ role: "user", content: "how do I make my python script faster" },
],
})
}),
)
it.live("fails the utility title on rate limits so the billable path can run", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => new Response("slow down", { status: 429 })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("429")
}),
)
it.live("fails the utility title on an empty completion", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => Response.json({ choices: [{ message: { content: null } }] })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("empty")
}),
)
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
Effect.gen(function* () {
const requests: Headers[] = []
@@ -26,7 +26,6 @@ import { Model } from "@opencode/schema/model"
import { asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
import legacyMedia from "./fixture/provider-context/2.0.14-media.json"
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
@@ -47,51 +46,6 @@ const replacement = [
const providerContext = SessionProviderContext.encode(target, replacement)
const sessionID = SessionSchema.ID.make("ses_provider_context")
// Frozen serialized data, deliberately not constructed using today's Message API.
const legacyContext = Schema.decodeUnknownSync(SessionProviderContext.Info)({
...providerContext,
messages: legacyMedia,
})
test("reads pre-2.0.15 checkpoint media without rewriting the stored payload", async () => {
const before = JSON.stringify(legacyContext)
const decoded = SessionProviderContext.decode(legacyContext)
expect(await Effect.runPromise(SessionProviderContext.validate(legacyContext))).toEqual(decoded)
expect(decoded[0]?.content).toMatchObject([
{ type: "text", text: "retained attachments" },
{
type: "media",
media: { source: { type: "base64", data: "AQID", mediaType: "image/png" } },
filename: "attachment.png",
cache: { type: "ephemeral" },
metadata: { attachment: "original" },
providerMetadata: { openai: { detail: "high" } },
},
{ type: "media", media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } } },
])
expect(decoded[1]?.content).toMatchObject(legacyMedia[1]?.content ?? [])
expect(JSON.stringify(legacyContext)).toBe(before)
})
test("reads mixed legacy and current media without overriding an existing asset", async () => {
const current = {
type: "media",
media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } },
mediaType: "image/png",
data: "AQID",
}
const context = Schema.decodeUnknownSync(SessionProviderContext.Info)({
...providerContext,
messages: [{ role: "user", content: [...(legacyMedia[0]?.content ?? []), current] }],
})
const decoded = SessionProviderContext.decode(context)
expect(await Effect.runPromise(SessionProviderContext.validate(context))).toEqual(decoded)
expect(decoded[0]?.content.at(-1)).toMatchObject({ media: current.media })
const invalid = { ...context, messages: [{ role: "user", content: [{ ...current, media: null }] }] }
expect(() => SessionProviderContext.decode(invalid)).toThrow()
expect(await Effect.runPromise(Effect.isFailure(SessionProviderContext.validate(invalid)))).toBe(true)
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
@@ -141,26 +95,6 @@ const setup = Effect.gen(function* () {
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
})
it.effect("loads and replays a persisted pre-2.0.15 media checkpoint", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("original request")
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* s.compact(legacyContext)
yield* s.prompt("continue")
const history = yield* SessionHistory.load(s.db, sessionID, target)
expect(toLLMMessages(history, model.ref).slice(0, 2)).toEqual([...SessionProviderContext.decode(legacyContext)])
expect(history.some(SessionProviderContext.isCheckpoint)).toBe(true)
const local = yield* SessionHistory.load(s.db, sessionID, "local")
expect(local.some(SessionProviderContext.isCheckpoint)).toBe(false)
expect(toLLMMessages(local, model.ref).map((message) => message.content)).toEqual([
[Message.text("original request")],
[Message.text("continue")],
])
}),
)
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
const messages = [
...replacement,
+24
View File
@@ -104,6 +104,30 @@ test("recognizes Claude version spellings and future models", () => {
])
})
test("keeps thinking on for Claude Opus 5.5 and later", () => {
const supports: Variant.Support[] = [{ type: "toggle" }, { type: "effort", values: ["low", "high"] }]
const adaptive = ["low", "high"].map((effort) => ({
id: effort,
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
}))
for (const input of [
model("@opencode/ai/providers/cloudflare-ai-gateway", "anthropic/claude-opus-5.5"),
model("@opencode/ai/providers/anthropic", "claude-opus-5-5"),
model("@opencode/ai/providers/anthropic", "claude-opus-6"),
])
expect(resolve(input, supports)).toEqual(adaptive)
expect(
resolve(model("@opencode/ai/providers/amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0"), [{ type: "toggle" }]),
).toEqual([])
for (const id of ["claude-opus-5", "claude-opus-5-20260901"])
expect(resolve(model("@opencode/ai/providers/anthropic", id), supports)).toEqual([
{ id: "none", settings: { thinking: { type: "disabled" } } },
...adaptive,
])
})
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
const pkg = "@opencode/ai/providers/cloudflare-ai-gateway"
expect(resolve(model(pkg, "openai/gpt-5.4"), [{ type: "effort", values: ["low", "xhigh"] }])).toEqual([
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.16",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.16",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
@@ -94,6 +94,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
+2 -2
View File
@@ -83,7 +83,7 @@ import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode/util/session-title-fallback"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import { openUrl } from "@opencode/util/open"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
@@ -1092,7 +1092,7 @@ function App() {
name: "docs.open",
title: "Open docs",
run: () => {
openUrl("https://opencode.ai/docs").catch(() => {})
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
},
category: "System",

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