mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-26 02:27:36 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241dc76ca5 | ||
|
|
beab7df087 |
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`, () =>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!" })
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.16",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())],
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,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,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": {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user