Compare commits

..

67 Commits

Author SHA1 Message Date
Kit Langton e1c04dcce6 feat(tui): add temporary new session tab (#39735) 2026-07-30 15:05:34 -04:00
Kit Langton 188c642d8c refactor(tui): flatten project picker list (#39726) 2026-07-30 14:18:16 -04:00
Kit Langton 9d55b223bb fix(core): stop repository discovery at nearest marker (#39714) 2026-07-30 13:28:29 -04:00
Kit Langton ce7a7e4e23 fix(tui): truncate project picker paths (#39678) 2026-07-30 13:13:31 -04:00
Kit Langton cba5ba03e3 fix(tui): show shells from session location (#39691) 2026-07-30 13:12:01 -04:00
Kit Langton 49081a4e24 fix(tui): flash tabs on prompt admission (#39702) 2026-07-30 16:57:36 +00:00
opencode-agent[bot] d6371f2fcd fix(session): update activity on prompt (#39539)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-30 16:28:10 +00:00
Aiden Cline 7625cbdf47 feat(core): route providers through native AI (#39615) 2026-07-30 11:26:18 -05:00
opencode-agent[bot] 0ffec67ca3 refactor: remove unused V2 code (#39699)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 11:25:03 -05:00
Kit Langton bd132f2614 refactor(core): skip redundant worktree lookup (#39692) 2026-07-30 16:15:07 +00:00
opencode-agent[bot] dcb0df4ac1 fix(core): stop MCP SSE error reconnect loops (#39671)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 10:53:33 -05:00
Kit Langton 02f3504055 fix(tui): hide redundant session directory labels (#39686) 2026-07-30 11:37:33 -04:00
James Long f23ee5e4a8 refactor(core): simplify formatter selection (#39575) 2026-07-30 10:59:01 -04:00
Kit Langton 77aa85c589 feat(tui): project picker with footer crossfade (#39566) 2026-07-30 10:39:22 -04:00
Dax Raad a618946b7e fix(tui): reload plugins with config 2026-07-30 10:31:09 -04:00
opencode-agent[bot] f9de608dea chore: refresh bun lockfile (#39617)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-30 04:36:21 +00:00
Aiden Cline 906dc8f5b2 fix(ai): apply catalog settings to provider models (#39613) 2026-07-29 22:42:47 -05:00
Aiden Cline 12971935ab feat(core): parse shell permission commands (#39567) 2026-07-29 22:42:18 -05:00
Kit Langton 78c139b8b5 feat(plugin): add ui.tabs API for session tab control (#39591) 2026-07-29 22:57:39 -04:00
Kit Langton c161978852 feat(tui): prefetch open session tabs after connect (#39589) 2026-07-29 22:23:13 -04:00
Kit Langton cb37a7166a feat(tui): make session tab switching fast for long transcripts (#39568) 2026-07-29 22:23:10 -04:00
Dax Raad 488445a679 fix(tui): correct project-aware session lists 2026-07-29 21:59:33 -04:00
opencode-agent[bot] 97786afdd8 refactor(core): share file diff construction (#39586)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-30 00:28:33 +00:00
Aiden Cline b1a5e8a6ae test(tui): restore compaction event lifecycle (#39581) 2026-07-29 18:11:58 -05:00
Aiden Cline 0ece10af43 fix(core): add mutation permission previews (#39578) 2026-07-29 18:04:13 -05:00
Aiden Cline d1a02b149c fix(core): clarify subagent tool guidance (#39572) 2026-07-29 16:48:49 -05:00
James Long 94ee274aeb feat(core): add V2 formatter runtime (#39564) 2026-07-29 17:23:04 -04:00
Kit Langton 3c259fc552 feat(tui): replace scrap screen with component storybook (#39548) 2026-07-29 15:51:15 -04:00
Aiden Cline 210be4b749 fix(core): preserve shell output on timeout (#39559) 2026-07-29 14:31:26 -05:00
James Long 464649e67e feat(tui): batch event delivery (#39551) 2026-07-29 15:20:39 -04:00
Aiden Cline c8dca936b1 feat(plugin): add shell.create.before hook (#39547) 2026-07-29 14:11:52 -05:00
Kit Langton 4f871906bc fix(tui): support cd before session creation (#39555) 2026-07-29 18:45:42 +00:00
Aiden Cline f7ea2fc346 test(cli): update ACP fork expectation (#39554) 2026-07-29 13:21:34 -05:00
Aiden Cline 5cb633a48e feat(core): support pinned Code Mode tools (#39550) 2026-07-29 13:16:49 -05:00
Kit Langton b985d2eb8e feat(tui): polish session tab animations (#39542) 2026-07-29 13:50:49 -04:00
Dax Raad 014908a8d7 feat(tui): reload config file changes 2026-07-29 13:05:48 -04:00
Dax Raad f599f8f3d3 fix(tui): guard Bun runtime plugin support 2026-07-29 12:57:35 -04:00
Aiden Cline b2010220f9 fix(core): clarify Code Mode tool boundary (#39540) 2026-07-29 11:56:53 -05:00
James Long c2e975c4e6 refactor(plugin): expose resolved TUI theme (#39536) 2026-07-29 12:51:55 -04:00
Dax Raad 6aa250ee5d refactor(tui): flatten state storage path 2026-07-29 12:39:21 -04:00
Dax Raad 8f1e3ff75c docs: record v1 to v2 database migration decisions 2026-07-29 12:37:00 -04:00
Dax Raad 5438dfb751 fix(tui): remove invalid model toasts 2026-07-29 12:36:56 -04:00
Dax Raad 4bd16d6f47 feat(tui): default tabs to cwd scope 2026-07-29 12:35:32 -04:00
Dax Raad 9ee337469d feat(tui): add persistent storage context 2026-07-29 12:33:18 -04:00
Aiden Cline 813c41ff6c fix(core): simplify shell execution boundary (#39530) 2026-07-29 11:16:45 -05:00
Dax Raad 247f14f955 feat(tui): add replaceable prompt footer slot 2026-07-29 10:16:34 -04:00
Dax Raad bd906d468d refactor(core): make watcher subscription effectful 2026-07-29 09:50:24 -04:00
Dax Raad fc11ed3838 feat(session): define explicit fork boundaries 2026-07-29 09:50:24 -04:00
Dax Raad 2a85c861e0 fix(session): hide pending admission sequence 2026-07-29 09:50:24 -04:00
Shoubhit Dash 9554f9a16e feat(ai): type Vertex request options (#39499) 2026-07-29 18:23:00 +05:30
Shoubhit Dash d72b428061 feat(ai): type Vertex Chat request options (#39503) 2026-07-29 18:22:41 +05:30
Shoubhit Dash fea17b4a0e feat(ai): type Cloudflare request options (#39507) 2026-07-29 18:22:16 +05:30
Shoubhit Dash 9038e44a68 feat(ai): type Copilot request options (#39496) 2026-07-29 18:22:01 +05:30
Shoubhit Dash 3b8299e3f2 feat(ai): type Azure request options (#39498) 2026-07-29 18:21:43 +05:30
Shoubhit Dash f5cdf0f056 feat(ai): type Anthropic request options (#39502) 2026-07-29 18:21:19 +05:30
Shoubhit Dash 224feff7c4 feat(ai): type compatible Responses options (#39506) 2026-07-29 18:20:55 +05:30
Shoubhit Dash ce2c9e7e26 feat(ai): type Google request options (#39504) 2026-07-29 18:20:30 +05:30
Shoubhit Dash cb80f47112 feat(ai): type Vertex Messages request options (#39501) 2026-07-29 18:20:04 +05:30
Shoubhit Dash b4ac939537 feat(ai): type xAI request options (#39505) 2026-07-29 18:19:44 +05:30
Shoubhit Dash 8a96b80aec feat(ai): type OpenAI request options (#39495) 2026-07-29 18:19:26 +05:30
Shoubhit Dash 333a090975 feat(ai): type Vertex Responses request options (#39500) 2026-07-29 18:19:02 +05:30
Shoubhit Dash 5a78a17e49 feat(ai): type OpenRouter request options (#39508) 2026-07-29 18:18:43 +05:30
Shoubhit Dash 9d6af6afa4 feat(ai): type compatible request options (#39509) 2026-07-29 18:18:24 +05:30
Shoubhit Dash 8c3e06798c refactor(ai): limit provider option inference (#39510) 2026-07-29 18:18:00 +05:30
Shoubhit Dash 5882b64612 feat(ai): type Anthropic-compatible request options (#39497) 2026-07-29 18:07:24 +05:30
Shoubhit Dash 309c4fe6f0 feat(ai): infer model provider options (#39493) 2026-07-29 18:05:56 +05:30
Shoubhit Dash d9555f138b refactor(ai): internalize request compilation (#39132) 2026-07-29 16:51:11 +05:30
342 changed files with 6810 additions and 8996 deletions
+7 -10
View File
@@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(context: Context) {
const timer = setTimeout(() => {
context.ui.toast.show({
title: "TUI plugin discovery works",
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
variant: "success",
duration: 30_000,
})
}, 1_000)
return () => clearTimeout(timer)
setup(_context: Context) {
// context.ui.toast.show({
// title: "TUI plugin discovery works",
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
// variant: "success",
// duration: 30_000,
// })
},
}
+29 -15
View File
@@ -143,7 +143,10 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -184,14 +187,12 @@
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"ws": "8.21.0",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/ws": "8.18.1",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
@@ -404,8 +405,11 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -445,7 +449,6 @@
"@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -603,6 +606,7 @@
"zod": "catalog:",
},
"devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -614,12 +618,14 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
@@ -1072,23 +1078,25 @@
},
},
"trustedDependencies": [
"web-tree-sitter",
"esbuild",
"electron",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -4072,10 +4080,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5634,6 +5642,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6856,6 +6868,8 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
+118
View File
@@ -0,0 +1,118 @@
# V1 to V2 Database Migration
## Approach
- Use the `dev` branch database schema and migration registry as the V1 baseline.
- Remove migrations that exist only on the V2 branch.
- Generate one canonical migration from the `dev` schema to the final V2 schema.
- Add explicit data operations to that migration where generated DDL is insufficient.
- Test the migration against a populated database at the exact `dev` schema.
## Preserve
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
workspace relationships.
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
generated migration must not drop the table.
## Truncate
Truncate these pre-launch V2 tables before applying schema changes:
- `event`
- `event_sequence`
- `session_message`
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
and `part` rows rather than retaining its pre-launch V2 contents.
## Message Backfill
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
V2 session APIs, which read `session_message`.
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
avoid rewriting other persisted state that may refer to a message.
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
contiguous `session_message.seq` values starting at `0`.
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
payload.
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
part mappings must be decided explicitly before implementing the backfill.
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
admitted compaction input ID and preserves references to the initiating message.
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
assistant row.
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
before migrated history. The `event` table remains empty.
## Drop
Drop these pre-launch V2 tables without preserving or transforming their rows:
- `session_input`
- `session_context_epoch`
Do not transfer `session_input` rows into `session_pending`.
## Create Empty
Let the generated migration create these tables empty:
- `instruction_blob`
- `instruction_entry`
- `instruction_state`
- `session_pending`
- `kv`
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
## Fork Storage
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
- `before`: copy messages before the identified message.
- `through`: copy messages through the identified message.
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
schema.
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
## Verification
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
credentials, permissions, shares, and workspaces. After migration, it should verify:
- Preserved rows and encoded values remain unchanged.
- Todo rows remain available in the unchanged `todo` table.
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
- Dropped tables no longer exist.
- New tables exist and are empty.
- The final schema has no ungenerated changes.
+3 -3
View File
@@ -46,7 +46,7 @@ const response = yield * LLMClient.generate(request)
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
@@ -138,13 +138,13 @@ packages/ai/src/
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.prepare/stream/generate
client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
-1
View File
@@ -196,7 +196,6 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
+1 -1
View File
@@ -568,7 +568,7 @@ App boundary = explicit durable-config -> typed-provider call
calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
- [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.
+2 -32
View File
@@ -50,18 +50,6 @@ const request = LLM.request({
},
})
// `http` is intentionally not needed for normal calls. This shows the shape for
// newly released provider fields before they deserve a typed provider option.
const rawOverlayExample = LLM.request({
model,
prompt: "Show the final HTTP overlay shape.",
http: {
body: { metadata: { example: "tutorial" } },
headers: { "x-opencode-tutorial": "1" },
query: { debug: "1" },
},
})
// 3. `generate` sends the request and collects the event stream into one
// response object. `response.text` is the collected text output.
const generateOnce = Effect.gen(function* () {
@@ -222,33 +210,15 @@ const FakeEcho = {
}),
}
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without
// sending anything over the network.
const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: FakeEcho.configure().model("tiny-echo"),
prompt: "Show me the provider pipeline.",
}),
)
console.log("\n== fake provider prepare ==")
console.log("route:", prepared.route)
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
})
// Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// or tool-loop behavior without spending tokens on every example.
// enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () {
// yield* generateOnce
// yield* inspectFakeProvider
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
// yield* streamText
// yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
+16 -11
View File
@@ -9,25 +9,28 @@ import {
LLMRequest,
LLMResponse,
Message,
Model,
SystemPart,
ToolChoice,
ToolDefinition,
type ContentPart,
type ModelProviderOptions,
} from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput = Omit<
export type RequestInput<SelectedModel extends Model = Model> = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
readonly model: SelectedModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>>
readonly http?: HttpOptions.Input
}
@@ -35,7 +38,7 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream
export const request = (input: RequestInput) => {
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => {
const {
system: requestSystem,
prompt,
@@ -63,7 +66,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice">
export class GenerateObjectResponse<T> {
constructor(
@@ -80,11 +83,13 @@ export class GenerateObjectResponse<T> {
}
}
export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase {
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
readonly schema: S
}
export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model>
extends GenerateObjectBase<SelectedModel> {
/** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema
}
@@ -137,11 +142,11 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/
export function generateObject<S extends ToolSchema<any>>(
options: GenerateObjectOptions<S>,
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
export function generateObject(
options: GenerateObjectDynamicOptions,
export function generateObject<const SelectedModel extends Model>(
options: GenerateObjectDynamicOptions<SelectedModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
+6 -3
View File
@@ -1,4 +1,4 @@
import type { Model } from "./schema"
import type { Model, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly headers?: Readonly<Record<string, string>>
@@ -9,8 +9,11 @@ export interface Settings extends Readonly<Record<string, unknown>> {
}
}
export interface Definition<ProviderSettings extends Settings = Settings> {
readonly model: (modelID: string, settings: ProviderSettings) => Model
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
}
export * as ProviderPackage from "./provider-package"
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -57,7 +57,10 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
+4 -1
View File
@@ -52,7 +52,10 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
+14 -6
View File
@@ -99,10 +99,14 @@ export const configure = (input: Config) => {
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
@@ -133,8 +137,12 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const model = responsesModel
+10 -4
View File
@@ -4,6 +4,7 @@ import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
@@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{
}
export type AIGatewayOptions = GatewayURL &
RouteDefaultsInput &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret
readonly providerOptions?: OpenAIProviderOptionsInput
}
type WorkersAIURL = AtLeastOne<{
@@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{
readonly baseURL: string
}>
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
export type WorkersAIOptions = WorkersAIURL &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
@@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
})
return {
id: aiGatewayID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureAIGateway,
}
}
@@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
})
return {
id: workersAIID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure: configureWorkersAI,
}
}
+4 -2
View File
@@ -50,9 +50,11 @@ export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
responsesRoute
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: (modelID: string | ModelID) =>
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: ProviderOptions
readonly providerOptions?: OpenAIProviderOptionsInput
}
const route = OpenAICompatibleChat.route.with({
@@ -56,7 +58,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -66,7 +68,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -101,7 +101,10 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
@@ -1,8 +1,9 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type ModelID } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("google-vertex")
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: ProviderOptions
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
const route = OpenAICompatibleResponses.route.with({
@@ -58,7 +60,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -68,7 +70,10 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
+6 -2
View File
@@ -77,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
export const configure = (input: Config = {}) => {
return {
id,
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
model: (modelID: string | ModelID) =>
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -86,7 +87,10 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
+2 -2
View File
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
})
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
image,
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
configure,
}
}
@@ -46,7 +46,10 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
@@ -4,13 +4,15 @@ import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("openai-compatible")
type GenericModelOptions = RouteDefaultsInput &
type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
@@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings {
readonly provider?: string
}
export type FamilyModelOptions = RouteDefaultsInput &
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleChat.route]
@@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => {
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
model: (modelID: string | ModelID) =>
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
configure,
}
}
@@ -63,7 +67,7 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+13 -6
View File
@@ -86,10 +86,15 @@ export const configure = (input: Config = {}) => {
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
@@ -132,15 +137,17 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const configured = configure(config(settings))
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
}
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
+20 -2
View File
@@ -5,6 +5,7 @@ import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
@@ -30,6 +31,12 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
@@ -107,10 +114,21 @@ export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
+51 -11
View File
@@ -1,29 +1,62 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
import type { OpenAIOptionsInput } from "./openai-options"
import type { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("xai")
export type ModelOptions = RouteDefaultsInput &
export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } },
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
})
export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAIResponses.route.with({
return responsesRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -31,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return OpenAICompatibleChat.route.with({
return chatRoute.with({
...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
@@ -42,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
@@ -63,7 +95,15 @@ export const configure = (input: ModelOptions = {}) => {
}
export const provider = configure()
export const model = provider.model
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+11 -29
View File
@@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
import {
GenerationOptions,
HttpOptions,
@@ -20,7 +20,6 @@ import {
ModelLimits,
LLMError as LLMErrorClass,
LLMEvent,
PreparedRequest,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -46,7 +45,7 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: (input: RouteMappedModelInput) => Model
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: (
prepared: Prepared,
@@ -93,12 +92,12 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make({
return Model.make<Options>({
...mapped,
provider,
route,
@@ -142,17 +141,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
/**
* Compile a request through protocol body construction, validation, and HTTP
* preparation without sending it. Returns the prepared request including the
* provider-native body.
*
* Pass a `Body` type argument to statically expose the route's body
* shape (e.g. `prepare<OpenAIChatBody>(...)`) — the runtime body is
* identical, so this is a type-level assertion the caller makes about which
* route the request will resolve to.
*/
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -296,7 +284,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults),
})
},
model: (input) => makeRouteModel(route, input),
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
makeRouteModel<Options>(route, input),
prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
@@ -370,9 +359,6 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
// `compile` is the important boundary: it turns a common `LLMRequest` into a
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
@@ -390,17 +376,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
}
})
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
/** @internal Test-only projection of the execution compiler; not exported from package barrels. */
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
const compiled = yield* compile(request)
return new PreparedRequest({
return {
id: compiled.request.id ?? "request",
route: compiled.route.id,
protocol: compiled.route.protocol,
model: compiled.request.model,
body: compiled.body,
metadata: { transport: compiled.route.transport.id },
})
}
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
@@ -422,9 +408,6 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export const prepare = <Body = unknown>(request: LLMRequest) =>
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
return Stream.unwrap(
Effect.gen(function* () {
@@ -453,7 +436,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
return Service.of({ stream, generate: generateWith(stream) })
}),
)
@@ -462,7 +445,6 @@ export const Route = { make } as const
export const LLMClient = {
Service,
layer,
prepare,
stream,
generate,
} as const
+1 -25
View File
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
id: Schema.String,
route: RouteID,
protocol: ProtocolID,
model: ModelSchema,
body: Schema.Unknown,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
/**
* A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
* on `LLMClient.prepare<Body>(...)` when the caller knows which route their
* request will resolve to and wants its native shape statically exposed
* (debug UIs, request previews, plan rendering).
*
* The runtime body is identical — the route still emits `body: unknown` — so
* this is a type-level assertion the caller makes about what they expect to
* find. The prepare runtime does not validate the assertion.
*/
export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
readonly body: Body
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.textDelta)
+9 -6
View File
@@ -178,7 +178,8 @@ export namespace ModelCompatibility {
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
export class Model {
export class Model<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
@@ -193,8 +194,8 @@ export class Model {
this.compatibility = input.compatibility
}
static make(input: Model.Input) {
return new Model({
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) {
return new Model<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -203,7 +204,7 @@ export class Model {
})
}
static input(model: Model): Model.ConstructorInput {
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -213,9 +214,9 @@ export class Model {
}
}
static update(model: Model, patch: Partial<Model.Input>) {
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) {
if (Object.keys(patch).length === 0) return model
return Model.make({
return Model.make<Options>({
...Model.input(model),
...patch,
})
@@ -241,6 +242,8 @@ export namespace Model {
export type ModelInput = Model.Input
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
-1
View File
@@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) =>
)
}) as LLMClientShape["stream"]
const client = LLMClient.Service.of({
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream,
generate: (request) =>
stream(request).pipe(
+3 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Model } from "../src/schema"
import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
@@ -139,8 +140,7 @@ describe("llm route", () => {
it.effect("selects routes by model route value", () =>
Effect.gen(function* () {
const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
)
@@ -173,7 +173,7 @@ describe("llm route", () => {
framing: fakeFraming,
})
const prepared = yield* (yield* LLMClient.Service).prepare(
const prepared = yield* compileRequest(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
)
+14 -13
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src"
import { Auth, LLMClient } from "../src/route"
import { Auth } from "../src/route"
import { compileRequest } from "../src/route/client"
import { AmazonBedrock } from "../src/providers"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as Gemini from "../src/protocols/gemini"
@@ -31,7 +32,7 @@ const geminiModel = Gemini.route
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "You are concise.",
@@ -50,7 +51,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: [
@@ -87,7 +88,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: openaiModel,
system: "Sys",
@@ -106,7 +107,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: geminiModel,
system: "Sys",
@@ -123,7 +124,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: bedrockModel,
system: [
@@ -157,7 +158,7 @@ describe("applyCachePolicy", () => {
it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -176,7 +177,7 @@ describe("applyCachePolicy", () => {
it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -195,7 +196,7 @@ describe("applyCachePolicy", () => {
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: [
@@ -241,7 +242,7 @@ describe("applyCachePolicy", () => {
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* LLMClient.prepare(request)
const prepared = yield* compileRequest(request)
const body = prepared.body as {
tools: Array<{ cache_control?: unknown }>
@@ -261,7 +262,7 @@ describe("applyCachePolicy", () => {
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
system: "Sys",
@@ -278,7 +279,7 @@ describe("applyCachePolicy", () => {
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
@@ -296,7 +297,7 @@ describe("applyCachePolicy", () => {
it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks"
@@ -44,7 +45,7 @@ describe("request option precedence", () => {
})
})
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
it.effect("compiles bodies with route defaults, model defaults, and call options in order", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
@@ -59,7 +60,7 @@ describe("request option precedence", () => {
providerOptions: { openai: { reasoningEffort: "medium" } },
},
})
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
@@ -141,7 +142,7 @@ describe("request option precedence", () => {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
@@ -164,10 +165,8 @@ describe("request option precedence", () => {
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
-2
View File
@@ -53,9 +53,7 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
@@ -0,0 +1,47 @@
import { Schema } from "effect"
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
readonly [key: string]: unknown
readonly mode?: "fast" | "thorough"
}
type ExampleProviderOptions = ProviderOptions & {
readonly example?: ExampleOptions
}
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" })
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Known provider options preserve their value types.
providerOptions: { example: { mode: "slow" } },
})
LLM.generateObject({
model,
prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } },
})
LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
// @ts-expect-error Dynamic object generation uses the selected model's provider options.
providerOptions: { example: { mode: false } },
})
declare const generic: Model
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { AnthropicCompatible } from "../../src/providers"
const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic effort must be a string.
providerOptions: { anthropic: { effort: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { Anthropic } from "../../src/providers"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Anthropic thinking modes are a fixed union.
providerOptions: { anthropic: { thinking: { type: "automatic" } } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { Azure } from "../../src/providers"
const model = Azure.configure({ resourceName: "example" }).responses("deployment")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Azure OpenAI store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
providerOptions: { openai: { promptCacheKey: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GitHubCopilot } from "../../src/providers"
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
providerOptions: { openai: { reasoningSummary: "full" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexChat } from "../../src/providers"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { openai: { serviceTier: "premium" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexMessages } from "../../src/providers"
const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Anthropic effort must be a string.
providerOptions: { anthropic: { effort: false } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GoogleVertexResponses } from "../../src/providers"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { openresponses: { textVerbosity: "verbose" } },
})
@@ -0,0 +1,17 @@
import { LLM } from "../../src"
import { GoogleVertex } from "../../src/providers"
const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Gemini includeThoughts must be boolean.
providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } },
})
@@ -0,0 +1,17 @@
import { LLM } from "../../src"
import { Google } from "../../src/providers"
const model = Google.provider.model("gemini-2.5-pro")
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAICompatibleResponses } from "../../src/providers"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Open Responses reasoning summaries use a fixed union.
providerOptions: { openresponses: { reasoningSummary: "full" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAICompatible } from "../../src/providers"
const model = OpenAICompatible.deepseek.model("deepseek-chat")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI-compatible store must be boolean.
providerOptions: { openai: { store: "false" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenAI } from "../../src/providers"
const model = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { OpenRouter } from "../../src/providers"
const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenRouter usage must be boolean or an option record.
providerOptions: { openrouter: { usage: "yes" } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { XAI } from "../../src/providers"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } },
})
+31
View File
@@ -21,6 +21,8 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -29,6 +31,35 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { openrouter: { usage: true } },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { xai: { reasoningEffort: "high" } },
})
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
import { it } from "../lib/effect"
@@ -44,7 +45,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro
describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request)
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "claude-sonnet-4-5",
@@ -59,7 +60,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
@@ -76,17 +77,17 @@ describe("Anthropic Messages route", () => {
it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () {
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const enabled = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}),
)
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const legacy = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}),
)
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const disabled = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}),
@@ -100,7 +101,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}),
@@ -112,7 +113,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -137,7 +138,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -164,7 +165,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -181,7 +182,7 @@ describe("Anthropic Messages route", () => {
it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () {
expect(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
(yield* compileRequest(
LLM.request({
model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
@@ -196,12 +197,11 @@ describe("Anthropic Messages route", () => {
},
])
expect(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
)).body.messages,
(yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" })))
.body.messages,
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
expect(
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
(yield* compileRequest(
LLM.request({
model: opus48,
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
@@ -223,7 +223,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares tool call and tool result messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => {
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_choice_none",
model,
@@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => {
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image",
model,
@@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image_only",
model,
@@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
continuationRequest({
id: "req_native_continuation_anthropic",
model,
@@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -628,9 +628,7 @@ describe("Anthropic Messages route", () => {
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
])
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
@@ -773,7 +771,7 @@ describe("Anthropic Messages route", () => {
),
),
)
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1133,7 +1131,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_round_trip",
model,
@@ -1184,7 +1182,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_unknown_server_tool",
model,
@@ -1261,7 +1259,7 @@ describe("Anthropic Messages route", () => {
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
@@ -1277,7 +1275,7 @@ describe("Anthropic Messages route", () => {
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
@@ -1318,7 +1316,7 @@ describe("Anthropic Messages route", () => {
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
system: [
@@ -1344,7 +1342,7 @@ describe("Anthropic Messages route", () => {
it.effect("spends breakpoint budget on tools before system before messages", () =>
Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [
@@ -13,6 +13,7 @@ import {
ToolDefinition,
} from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect"
@@ -101,7 +102,7 @@ const baseRequest = LLM.request({
describe("Bedrock Converse route", () => {
it.effect("prepares Converse target with system, inference config, and messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(baseRequest)
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -114,7 +115,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}),
@@ -129,14 +130,14 @@ describe("Bedrock Converse route", () => {
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -153,7 +154,7 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -187,7 +188,7 @@ describe("Bedrock Converse route", () => {
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
@@ -217,7 +218,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_history",
model,
@@ -256,7 +257,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_image",
model,
@@ -491,7 +492,7 @@ describe("Bedrock Converse route", () => {
providerMetadata: { bedrock: { signature: "sig_1" } },
})
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => {
},
])
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
expect(prepared.body.messages).toEqual([
{
role: "assistant",
@@ -639,7 +638,7 @@ describe("Bedrock Converse route", () => {
text: "",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -754,7 +753,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed)
@@ -764,7 +763,7 @@ describe("Bedrock Converse route", () => {
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_cache",
model,
@@ -796,7 +795,7 @@ describe("Bedrock Converse route", () => {
it.effect("does not emit cachePoint when no cache hint is set", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(baseRequest)
const prepared = yield* compileRequest(baseRequest)
expect(prepared.body).toMatchObject({
system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
@@ -806,7 +805,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image media into Bedrock image blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_image",
model,
@@ -843,7 +842,7 @@ describe("Bedrock Converse route", () => {
it.effect("base64-encodes Uint8Array image bytes", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_image_bytes",
model,
@@ -865,7 +864,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_doc",
model,
@@ -897,7 +896,7 @@ describe("Bedrock Converse route", () => {
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
@@ -910,7 +909,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
@@ -936,7 +935,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
@@ -988,7 +987,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_bad_image",
model,
@@ -1002,7 +1001,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported document media types", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_bad_doc",
model,
@@ -1017,7 +1016,7 @@ describe("Bedrock Converse route", () => {
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
system: [{ type: "text", text: "system", cache }],
@@ -1034,7 +1033,7 @@ describe("Bedrock Converse route", () => {
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
@@ -1066,7 +1065,7 @@ describe("Bedrock Converse route", () => {
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
system: [
+5 -5
View File
@@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -34,7 +34,7 @@ describe("Cloudflare", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-ai-gateway")
expect(prepared.body).toMatchObject({
@@ -129,7 +129,7 @@ describe("Cloudflare", () => {
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
@@ -180,7 +180,7 @@ describe("Cloudflare", () => {
it.effect("allows a fully configured baseURL override", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: CloudflareAIGateway.configure({
baseURL: "https://gateway.proxy.test/v1/custom/compat",
@@ -208,7 +208,7 @@ describe("Cloudflare", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-workers-ai")
expect(prepared.body).toMatchObject({
+14 -13
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared"
import { it } from "../lib/effect"
@@ -26,7 +27,7 @@ const request = LLM.request({
describe("Gemini route", () => {
it.effect("prepares Gemini target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request)
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
@@ -38,12 +39,12 @@ describe("Gemini route", () => {
it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
}),
)
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
const filtered = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}),
@@ -59,7 +60,7 @@ describe("Gemini route", () => {
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -75,7 +76,7 @@ describe("Gemini route", () => {
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -143,7 +144,7 @@ describe("Gemini route", () => {
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -188,7 +189,7 @@ describe("Gemini route", () => {
it.effect("strips matching data URLs to raw base64 inlineData", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -229,7 +230,7 @@ describe("Gemini route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -238,7 +239,7 @@ describe("Gemini route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -256,7 +257,7 @@ describe("Gemini route", () => {
it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_choice_none",
model,
@@ -276,7 +277,7 @@ describe("Gemini route", () => {
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_schema_patch",
model,
@@ -457,7 +458,7 @@ describe("Gemini route", () => {
response.events.findIndex((event) => event.type === "tool-call"),
)
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -691,7 +692,7 @@ describe("Gemini route", () => {
it.effect("rejects unsupported assistant media content", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_media",
model,
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { deltaChunk, finishChunk } from "../lib/openai-chunks"
@@ -182,7 +183,7 @@ describe("Google Vertex providers", () => {
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
@@ -5,6 +5,7 @@ import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
@@ -84,9 +85,7 @@ for (const item of cases) {
),
).toBe(true)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
+29 -48
View File
@@ -18,6 +18,7 @@ import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
@@ -42,11 +43,7 @@ const request = LLM.request({
describe("OpenAI Chat route", () => {
it.effect("prepares OpenAI Chat payload", () =>
Effect.gen(function* () {
// Pass the OpenAIChat payload type so `prepared.body` is statically
// typed to the route's native shape — the assertions below read field
// names without `unknown` casts.
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(request)
const _typed: { readonly model: string; readonly stream: true } = prepared.body
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
@@ -64,7 +61,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -87,7 +84,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -105,7 +102,7 @@ describe("OpenAI Chat route", () => {
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
@@ -131,7 +128,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
@@ -144,7 +141,7 @@ describe("OpenAI Chat route", () => {
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
@@ -159,7 +156,7 @@ describe("OpenAI Chat route", () => {
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "think",
@@ -253,7 +250,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -291,7 +288,7 @@ describe("OpenAI Chat route", () => {
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -311,7 +308,7 @@ describe("OpenAI Chat route", () => {
it.effect("continues image tool results as vision input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -355,7 +352,7 @@ describe("OpenAI Chat route", () => {
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -405,7 +402,7 @@ describe("OpenAI Chat route", () => {
it.effect("aggregates consecutive tool images with a following system update", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -446,7 +443,7 @@ describe("OpenAI Chat route", () => {
it.effect("appends system updates without replacing multipart user content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -474,7 +471,7 @@ describe("OpenAI Chat route", () => {
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -483,7 +480,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -501,7 +498,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares raw and data URL image media as vision input", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_media",
model,
@@ -528,7 +525,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers reasoning-only assistant history", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning",
model,
@@ -619,9 +616,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: field },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
}
}),
@@ -647,9 +642,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "vendor_reasoning" },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
}),
)
@@ -692,9 +685,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
@@ -737,9 +728,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
@@ -764,9 +753,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
@@ -839,9 +826,7 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: [] },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
@@ -889,9 +874,7 @@ describe("OpenAI Chat route", () => {
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
@@ -918,9 +901,7 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
@@ -950,7 +931,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const replay = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -979,7 +960,7 @@ describe("OpenAI Chat route", () => {
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const replay = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1004,7 +985,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const replay = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
@@ -52,7 +53,7 @@ const providerFamilies = [
describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
toolChoice: ToolChoice.make({ type: "required" }),
@@ -127,7 +128,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request)
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "deepseek-chat",
@@ -145,7 +146,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_parity",
model,
@@ -7,6 +7,7 @@ import { OpenResponses } from "../../src/protocols/open-responses"
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
import { OpenAIResponses } from "../../src/protocols/openai-responses"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
@@ -23,7 +24,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
system: "You are concise.",
@@ -61,7 +62,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip)
@@ -76,7 +77,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -102,7 +103,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
@@ -14,6 +14,7 @@ import {
Usage,
} from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai"
@@ -56,7 +57,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request)
const prepared = yield* compileRequest(request)
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
@@ -74,7 +75,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -92,7 +93,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
@@ -109,7 +110,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
const prepared = yield* LLMClient.prepare(input)
const prepared = yield* compileRequest(input)
expect(prepared.body).toMatchObject({ service_tier: "priority" })
expect(prepared.body).not.toHaveProperty("serviceTier")
@@ -118,7 +119,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
@@ -128,7 +129,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
)
@@ -138,7 +139,7 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({
@@ -191,7 +192,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -217,7 +218,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -395,7 +396,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result",
model,
@@ -432,7 +433,7 @@ describe("OpenAI Responses route", () => {
content: [],
structured: {},
}
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -453,7 +454,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps primitive tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -469,7 +470,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps non-JSON tool errors as plain text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -487,7 +488,7 @@ describe("OpenAI Responses route", () => {
// image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image",
model,
@@ -516,7 +517,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_image_only",
model,
@@ -540,7 +541,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_pdf",
model,
@@ -575,7 +576,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
@@ -610,7 +611,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -633,7 +634,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
continuationRequest({
id: "req_native_continuation_openai",
model,
@@ -675,7 +676,7 @@ describe("OpenAI Responses route", () => {
it.effect("maps OpenAI provider options to Responses options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
@@ -700,7 +701,7 @@ describe("OpenAI Responses route", () => {
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
@@ -722,7 +723,7 @@ describe("OpenAI Responses route", () => {
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
@@ -739,7 +740,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
@@ -749,7 +750,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
@@ -759,7 +760,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits include when no include is set", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
@@ -773,7 +774,7 @@ describe("OpenAI Responses route", () => {
// reasoningSummary: "auto" by default. Without `include`, a follow-up
// turn cannot replay reasoning state, so the facade also opts into
// `reasoning.encrypted_content` automatically.
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -788,7 +789,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
@@ -802,7 +803,7 @@ describe("OpenAI Responses route", () => {
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
@@ -934,9 +935,7 @@ describe("OpenAI Responses route", () => {
},
])
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, messages: [response.message] }),
)
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
role: "assistant",
@@ -1270,7 +1269,7 @@ describe("OpenAI Responses route", () => {
it.effect("preserves assistant content order around reasoning items", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_order",
model,
@@ -1308,7 +1307,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1330,7 +1329,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored provider-executed hosted tool results by id", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1367,7 +1366,7 @@ describe("OpenAI Responses route", () => {
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
@@ -1408,7 +1407,7 @@ describe("OpenAI Responses route", () => {
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_multi_summary_continuation",
model,
@@ -1445,7 +1444,7 @@ describe("OpenAI Responses route", () => {
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_without_encrypted_state",
model,
@@ -1762,7 +1761,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
id: "req_media",
model,
@@ -1793,7 +1792,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
@@ -1825,7 +1824,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
const error = yield* compileRequest(
LLM.request({
id: "req_media",
model,
+7 -6
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http"
@@ -19,7 +20,7 @@ describe("OpenRouter", () => {
})
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("openrouter")
expect(prepared.body).toMatchObject({
@@ -32,7 +33,7 @@ describe("OpenRouter", () => {
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
@@ -100,7 +101,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -133,7 +134,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -158,7 +159,7 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
@@ -179,7 +180,7 @@ describe("OpenRouter", () => {
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
@@ -3,7 +3,8 @@ import { Effect } from "effect"
import { LLM } from "../src"
import { OpenAIChat } from "../src/protocols"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
import { Auth, LLMClient } from "../src/route"
import { Auth } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect"
describe("tool schema projections", () => {
@@ -79,7 +80,7 @@ describe("tool schema projections", () => {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
-1
View File
@@ -5,7 +5,6 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./browser-pane": "./src/browser-pane.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
-40
View File
@@ -1,40 +0,0 @@
import type { ServerProtocol } from "./utils/server-protocol"
export type BrowserPaneTarget = Readonly<{ sessionID: string }>
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneBinding = BrowserPaneTarget &
Readonly<{ bindingID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneBounds = { x: number; y: number; width: number; height: number }
export type BrowserPaneLayout = {
visible: boolean
bounds?: BrowserPaneBounds
}
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
close(): void
}
export type BrowserPanePlatform = {
register(binding: BrowserPaneBinding, onOpen: () => void): BrowserPaneRegistration
}
export function browserPaneAvailable(input: {
platform: boolean
sessionID?: string
protocol?: ServerProtocol
}) {
return input.platform && !!input.sessionID && input.protocol === "v2"
}
export function createBrowserPaneBinding(input: BrowserPaneTarget & { endpoint: BrowserPaneEndpoint }) {
return {
sessionID: input.sessionID,
bindingID: globalThis.crypto.randomUUID(),
endpoint: input.endpoint,
} satisfies BrowserPaneBinding
}
+1 -1
View File
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, messageID: item.id })
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
@@ -168,6 +168,7 @@ export function sanitizeProject(project: Project) {
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined,
}
}
-14
View File
@@ -5,17 +5,6 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { BrowserPanePlatform } from "../browser-pane"
export type {
BrowserPaneBinding,
BrowserPaneBounds,
BrowserPaneEndpoint,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneTarget,
} from "../browser-pane"
export { browserPaneAvailable, createBrowserPaneBinding } from "../browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -134,9 +123,6 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
@@ -26,7 +26,6 @@ function setup(
return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
+10 -6
View File
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "" },
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
},
data,
})
@@ -229,7 +229,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
],
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
@@ -255,7 +254,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
})),
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
@@ -280,7 +278,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
modelID: value.model.modelID,
})
return {
admittedSeq: 0,
id: value.id ?? "",
sessionID: value.sessionID,
timeCreated: Date.now(),
@@ -301,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: {
...input.current.project,
async list() {
return ((await legacy().project.list()).data ?? []) as Project[]
return ((await legacy().project.list()).data ?? []).map((project) => ({
...project,
canonical: project.worktree,
}))
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
return {
id: result.data.id,
directory: result.data.worktree,
canonical: result.data.worktree,
} satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
+3
View File
@@ -40,6 +40,9 @@
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
+1 -1
View File
@@ -62,8 +62,8 @@ for (const target of targets) {
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await build(mainConfig(input))
await copyNodeAssets(assets)
await build(mainConfig(input))
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+5 -1
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
+4 -1
View File
@@ -244,7 +244,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {}
},
forkSession: async (params) => {
const forked = await input.client.session.fork({ sessionID: params.sessionId })
const forked = await input.client.session.fork({
sessionID: params.sessionId,
boundary: { type: "through" },
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
+5
View File
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
+6 -4
View File
@@ -105,7 +105,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
.fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -118,9 +118,11 @@ async function selectSession(input: {
if (!selected) return { session: undefined, location }
return {
session: input.fork
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
throw new SessionTargetMutationError(error)
})
? await input.client.session
.fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
: selected,
}
}
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: {},
body: { boundary: { type: "through" } },
})
})
+1 -1
View File
@@ -267,7 +267,7 @@ async function run(input: {
values.push(...input.turn(messageID))
wake?.()
wake = undefined
return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
})
await runNonInteractivePrompt({
client: sdk,
+1 -1
View File
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
function location(directory: string, workspaceID?: string): LocationGetOutput {
return { directory, workspaceID, project: { id: "project", directory } }
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
+10 -1
View File
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
const dir = import.meta.dirname
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
+2 -52
View File
@@ -1,67 +1,17 @@
# @opencode-ai/client
Promise and Effect clients derived from OpenCode's authoritative Effect `HttpApi`, plus handwritten Node transports.
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
## Entrypoints
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/node`: Promise client plus Node-hosted browser attachments.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
The Promise root remains structural and has no Core, Effect, Schema, Protocol, or WebSocket runtime dependency. `/node` adds Effect, Schema, Protocol, and `ws`, but never Core or Server. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce these import graphs.
## Node browser attachments
The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Chromium hosts provide a small platform port once with `BrowserDriver.chromium`; the SDK owns command semantics, CDP input, snapshots, generations, cancellation, and limits.
```ts
import { BrowserDriver, OpenCode } from "@opencode-ai/client/node"
const driver = BrowserDriver.chromium(async ({ proxy, signal }) => {
const view = await createChromiumView({ proxy, signal })
return {
resource: view,
state: () => view.state(),
subscribe: (listener) => view.subscribe((state, mainDocumentChanged) => listener({ state, mainDocumentChanged })),
navigate: (url) => view.navigate(url),
back: () => view.back(),
forward: () => view.forward(),
reload: () => view.reload(),
stop: () => view.stop(),
send: (method, params) => view.sendCDP(method, params),
viewport: () => view.viewport(),
screenshot: ({ maxDimension }) => view.capturePNG({ maxDimension }),
dispose: () => view.close(),
}
})
const client = OpenCode.make({
baseUrl: "https://opencode.example",
headers: { authorization: `Basic ${credentials}` },
})
const registration = await client.browser.register({
sessionID,
open: () => showBrowserPane(),
})
const attachment = await registration.attach({ driver })
await attachment.resource.navigate("example.com")
const view = attachment.resource.resource
await attachment.close()
await registration.close()
```
`register` owns one control WebSocket for one Session. Its `open` callback is invoked when the server requests the browser pane. `attach` resolves only after the server acknowledges the exact lease; closing an attachment leaves its registration connected for a later `open`, while closing the registration closes the socket.
Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state.
Port state events set `mainDocumentChanged` only when the main document changes; this advances the public generation and invalidates element refs. `send` must dispatch CDP calls in invocation order. `screenshot` returns PNG bytes and dimensions, proportionally scaled to `maxDimension` without upscaling. The returned controller serializes local navigation with remote commands; `stop` immediately interrupts active work, and controller disposal is idempotent. An aborted or timed-out operation that reached the platform disposes the port so late native completion cannot cross the queue fence.
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. `BrowserDriver.define` remains the advanced escape hatch for non-Chromium semantics; throw `BrowserDriverError` for typed command failures there. Structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`.
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
Effect consumers construct canonical decoded inputs:
+3 -7
View File
@@ -17,7 +17,6 @@
],
"exports": {
".": "./src/promise/index.ts",
"./node": "./src/node/index.ts",
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.ts",
@@ -29,14 +28,12 @@
"build": "bun run script/build-package.ts",
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000 && bun run test:node-package",
"test:node-package": "bun test ./test/node/package-smoke.ts --timeout 60000",
"typecheck": "tsgo --noEmit && tsgo -p test/types/tsconfig.json --noEmit"
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"ws": "8.21.0"
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.101"
@@ -51,7 +48,6 @@
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/ws": "8.18.1",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
-1
View File
@@ -7,4 +7,3 @@ process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
await $`bun build src/node/index.ts --outfile dist/node/index.js --target=node --format=esm --packages=external`
+5 -3
View File
@@ -136,7 +136,7 @@ export type Endpoint5_4Input = { readonly sessionID: Session.ID }
export type Endpoint5_4Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly messageID?: SessionMessage.ID | undefined }
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_5Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
@@ -342,8 +342,10 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly parentSeq: number
readonly from?: SessionMessage.ID | undefined
readonly boundary: Session.ForkBoundary
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
}
}
| {
@@ -329,7 +329,7 @@ const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Inp
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect<Endpoint5_5Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -1,696 +0,0 @@
import { Browser } from "@opencode-ai/schema/browser"
import {
BrowserDriverError,
type BrowserDriver,
type BrowserDriverContext,
type BrowserDriverInstance,
} from "./driver.js"
type ChromiumViewState = Omit<Browser.State, "generation">
type ChromiumViewEvent = { readonly state: ChromiumViewState; readonly mainDocumentChanged: boolean }
type ChromiumCommands = {
"Runtime.evaluate": { readonly expression: string }
"Runtime.callFunctionOn": {
readonly objectId: string
readonly functionDeclaration: string
readonly arguments?: ReadonlyArray<{ readonly value: string }>
readonly returnByValue: true
}
"Runtime.releaseObject": { readonly objectId: string }
"Input.dispatchMouseEvent": {
readonly type: "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel"
readonly x: number
readonly y: number
readonly button?: "left"
readonly clickCount?: 1
readonly deltaX?: number
readonly deltaY?: number
}
"Input.dispatchKeyEvent": {
readonly type: "keyDown" | "keyUp"
readonly key: string
readonly code: string
readonly modifiers?: number
readonly windowsVirtualKeyCode?: number
}
"Input.insertText": { readonly text: string }
}
type ChromiumCommand = {
[Method in keyof ChromiumCommands]: { readonly method: Method; readonly params: ChromiumCommands[Method] }
}[keyof ChromiumCommands]
/** Platform primitives used by the shared semantic driver. */
export interface ChromiumPort<Resource> {
readonly resource: Resource; readonly state: () => ChromiumViewState
readonly subscribe: (listener: (event: ChromiumViewEvent) => void) => () => void
readonly navigate: (url: string) => PromiseLike<void>; readonly back: () => PromiseLike<void> | void
readonly forward: () => PromiseLike<void> | void; readonly reload: () => PromiseLike<void> | void
readonly stop: () => void; readonly send: (command: ChromiumCommand) => PromiseLike<unknown>
readonly viewport: () => { readonly width: number; readonly height: number }
readonly screenshot: (maxDimension: number) => PromiseLike<{
readonly data: Uint8Array
readonly width: number
readonly height: number
}>
readonly dispose: () => PromiseLike<void> | void
}
export interface ChromiumController<Resource> extends AsyncDisposable {
readonly resource: Resource; readonly state: () => Browser.State
readonly subscribe: (listener: (state: Browser.State) => void) => () => void
readonly navigate: (url: string) => Promise<void>; readonly back: () => Promise<void>
readonly forward: () => Promise<void>; readonly reload: () => Promise<void>
readonly stop: () => void; readonly dispose: () => Promise<void>
}
export type ChromiumDriver<Resource> = BrowserDriver<ChromiumController<Resource>>
type SnapshotNode = {
readonly token?: string; readonly role: string; readonly name: string; readonly value: string; readonly depth: number
readonly checked?: boolean; readonly disabled?: boolean; readonly expanded?: boolean; readonly selected?: boolean
}
type Page<Resource> = {
readonly port: ChromiumPort<Resource>; readonly lifetime: AbortSignal
readonly refs: Set<string>; readonly listeners: Set<(state: Browser.State) => void>
state: ChromiumViewState; document: number; nextRef: number; snapshotObjectID?: string
active?: AbortController; unsubscribe?: () => void; queue: Promise<void>
disposed: boolean; disposal?: Promise<void>
}
const commandTimeout = 10_000
const snapshotLimit = 500
const screenshotDimensionLimit = 2_000
const screenshotByteLimit = 5 * 1_024 * 1_024
export function chromiumDriver<Resource>(
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
): ChromiumDriver<Resource> {
return async (context) => {
const port = await create(context)
if (!context.signal.aborted) return createInstance(port, context.signal)
await Promise.resolve(port.dispose())
throw context.signal.reason instanceof Error ? context.signal.reason : new Error("Chromium driver creation was aborted")
}
}
function createInstance<Resource>(
port: ChromiumPort<Resource>,
lifetime: AbortSignal,
): BrowserDriverInstance<ChromiumController<Resource>> {
const page: Page<Resource> = {
port,
lifetime,
refs: new Set(),
listeners: new Set(),
state: port.state(),
document: 0,
nextRef: 0,
queue: Promise.resolve(),
disposed: false,
}
page.unsubscribe = port.subscribe((event) => {
if (page.disposed) return
if (event.mainDocumentChanged) {
page.document++
invalidateRefs(page)
}
page.state = event.state
publish(page)
})
const dispose = () => disposePage(page)
const controller: ChromiumController<Resource> = Object.freeze({
resource: port.resource,
state: () => state(page),
subscribe: (listener) => subscribe(page, listener),
navigate: (url) => schedule(page, undefined, (signal) => navigate(page, url, signal)),
back: () => localAction(page, () => port.back()),
forward: () => localAction(page, () => port.forward()),
reload: () => localAction(page, () => port.reload()),
stop: () => stop(page),
dispose,
[Symbol.asyncDispose]: dispose,
})
return Object.freeze({
resource: controller,
state: controller.state,
subscribe: controller.subscribe,
execute: (command: Browser.Command, options: { readonly signal: AbortSignal }) =>
schedule(page, options.signal, (signal) => execute(page, command, signal)),
dispose,
})
}
async function execute<Resource>(page: Page<Resource>, command: Browser.Command, signal: AbortSignal) {
assertDocument(page, command.generation)
switch (command.type) {
case "navigate":
await navigate(page, command.url, signal)
return { type: "navigate", state: state(page) } as const
case "snapshot":
return snapshot(page, command.generation, signal)
case "click":
await click(page, command.ref, command.generation, signal)
return { type: "click", state: refresh(page) } as const
case "fill":
await fill(page, command.ref, command.text, command.generation, signal)
return { type: "fill", state: refresh(page) } as const
case "press":
await press(page, command.key, command.generation, signal)
return { type: "press", state: refresh(page) } as const
case "scroll":
await scroll(page, command.direction, command.pixels, command.generation, signal)
return { type: "scroll", state: refresh(page) } as const
case "screenshot":
return screenshot(page, command.generation, signal)
}
}
async function navigate<Resource>(page: Page<Resource>, input: string, signal: AbortSignal) {
const url = normalizeURL(input)
const onAbort = () => page.port.stop()
signal.addEventListener("abort", onAbort, { once: true })
await bounded(() => page.port.navigate(url), signal, 30_000, "The browser navigation timed out.")
.catch((error) => {
if (signal.aborted) throw error
if (error instanceof BrowserDriverError) throw error
throw browserError("navigation_failed", error instanceof Error ? error.message : String(error))
})
.finally(() => signal.removeEventListener("abort", onAbort))
refresh(page)
}
async function snapshot<Resource>(page: Page<Resource>, generation: number, signal: AbortSignal) {
const object = await send(
page,
{ method: "Runtime.evaluate", params: { expression: snapshotExpression(page.nextRef) } },
signal,
)
const objectID = runtimeObjectID(object)
const result = await send(
page,
{
method: "Runtime.callFunctionOn",
params: {
objectId: objectID,
functionDeclaration: "function() { return this.result }",
returnByValue: true,
},
},
signal,
)
.then(readSnapshot)
.catch((error) => {
release(page, objectID)
throw error
})
assertDocument(page, generation)
invalidateRefs(page)
page.snapshotObjectID = objectID
page.nextRef = Math.max(page.nextRef, result.nextRef)
const lines = result.nodes.map((node) => {
if (node.token) page.refs.add(node.token)
const flags = [
node.checked === undefined ? undefined : `checked=${node.checked}`,
node.disabled === undefined ? undefined : `disabled=${node.disabled}`,
node.expanded === undefined ? undefined : `expanded=${node.expanded}`,
node.selected === undefined ? undefined : `selected=${node.selected}`,
].filter((value): value is string => value !== undefined)
const details = [
node.name ? JSON.stringify(node.name) : undefined,
node.value && node.value !== node.name ? `value=${JSON.stringify(node.value)}` : undefined,
].filter((value): value is string => value !== undefined)
return `${" ".repeat(node.depth)}${node.token ? `${node.token} ` : ""}[${node.role}]${details.length ? ` ${details.join(" ")}` : ""}${flags.length ? ` ${flags.join(" ")}` : ""}`
})
const current = page.port.state()
const content = [
`Page: ${current.title.replaceAll(/\s+/g, " ").trim().slice(0, 1_024)}`,
`URL: ${current.url.slice(0, 16_384)}`,
"",
...lines,
]
.join("\n")
.slice(0, 40 * 1_024)
return { type: "snapshot", state: refresh(page), format: "opencode.semantic.v1", content } as const
}
async function click<Resource>(page: Page<Resource>, ref: Browser.Ref, generation: number, signal: AbortSignal) {
const objectID = resolveRef(page, ref)
const point = await send(
page,
{
method: "Runtime.callFunctionOn",
params: {
objectId: objectID,
functionDeclaration:
"function(token) { const element = this.refs[token]; if (!element || !element.isConnected) throw new Error('stale element'); element.scrollIntoView({ block: 'center', inline: 'center' }); const bounds = element.getBoundingClientRect(); if (bounds.width <= 0 || bounds.height <= 0) throw new Error('element has no bounds'); return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 } }",
arguments: [{ value: ref }],
returnByValue: true,
},
},
signal,
).then(readPoint)
assertDocument(page, generation)
await send(page, { method: "Input.dispatchMouseEvent", params: { type: "mouseMoved", ...point } }, signal)
await inputPair(
() =>
send(
page,
{
method: "Input.dispatchMouseEvent",
params: { type: "mousePressed", button: "left", clickCount: 1, ...point },
},
signal,
),
() =>
send(page, {
method: "Input.dispatchMouseEvent",
params: { type: "mouseReleased", button: "left", clickCount: 1, ...point },
}),
)
assertDocument(page, generation)
}
async function fill<Resource>(
page: Page<Resource>,
ref: Browser.Ref,
text: string,
generation: number,
signal: AbortSignal,
) {
const editable = await send(
page,
{
method: "Runtime.callFunctionOn",
params: {
objectId: resolveRef(page, ref),
functionDeclaration: fillFunction,
arguments: [{ value: ref }],
returnByValue: true,
},
},
signal,
).then(runtimeValue)
assertDocument(page, generation)
if (editable !== true) throw browserError("stale_ref", "The browser element is not editable. Call browser_snapshot again.")
const select = { key: "a", code: "KeyA", modifiers: process.platform === "darwin" ? 4 : 2 }
await keyPair(page, select, signal)
await keyPair(page, { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, signal)
await send(page, { method: "Input.insertText", params: { text } }, signal)
assertDocument(page, generation)
}
async function press<Resource>(page: Page<Resource>, key: Browser.Key, generation: number, signal: AbortSignal) {
await keyPair(page, keyInfo(key), signal)
assertDocument(page, generation)
}
async function scroll<Resource>(
page: Page<Resource>,
direction: Browser.Direction,
pixels: number,
generation: number,
signal: AbortSignal,
) {
const viewport = page.port.viewport()
const distance = Math.min(2_000, Math.max(1, pixels))
await send(
page,
{
method: "Input.dispatchMouseEvent",
params: {
type: "mouseWheel",
x: Math.max(0, Math.round(viewport.width / 2)),
y: Math.max(0, Math.round(viewport.height / 2)),
deltaX: direction === "left" ? -distance : direction === "right" ? distance : 0,
deltaY: direction === "up" ? -distance : direction === "down" ? distance : 0,
},
},
signal,
)
assertDocument(page, generation)
}
async function screenshot<Resource>(page: Page<Resource>, generation: number, signal: AbortSignal) {
const source = await bounded(
() => page.port.screenshot(screenshotDimensionLimit),
signal,
commandTimeout,
"The browser screenshot timed out.",
)
assertDocument(page, generation)
if (source.data.byteLength > screenshotByteLimit) {
throw browserError("result_too_large", "The browser screenshot exceeds 5 MiB.")
}
if (
!Number.isSafeInteger(source.width) ||
!Number.isSafeInteger(source.height) ||
source.width < 1 ||
source.height < 1 ||
source.width > screenshotDimensionLimit ||
source.height > screenshotDimensionLimit
) {
throw browserError("internal", "The browser pane has no drawable area.")
}
return {
type: "screenshot",
state: refresh(page),
mediaType: "image/png",
data: new Uint8Array(source.data),
width: source.width,
height: source.height,
} as const
}
function schedule<Resource, Result>(
page: Page<Resource>,
signal: AbortSignal | undefined,
run: (signal: AbortSignal) => Promise<Result>,
) {
assertAttached(page)
throwIfAborted(signal)
const result = page.queue.then(async () => {
assertAttached(page)
throwIfAborted(signal)
const controller = new AbortController()
page.active = controller
const combined = AbortSignal.any([page.lifetime, controller.signal, ...(signal ? [signal] : [])])
return run(combined).finally(() => {
if (page.active === controller) page.active = undefined
})
})
page.queue = result.then(
() => undefined,
() => undefined,
)
return result.catch((error) => {
throw normalizeError(error)
})
}
function localAction<Resource>(page: Page<Resource>, run: () => PromiseLike<void> | void) {
return schedule(page, undefined, async (signal) => {
throwIfAborted(signal)
await run()
throwIfAborted(signal)
})
}
function stop<Resource>(page: Page<Resource>) {
assertAttached(page)
page.active?.abort()
try {
page.port.stop()
} catch (error) {
throw normalizeError(error)
}
}
function subscribe<Resource>(page: Page<Resource>, listener: (state: Browser.State) => void) {
assertAttached(page)
page.listeners.add(listener)
listener(state(page))
return () => page.listeners.delete(listener)
}
function publish<Resource>(page: Page<Resource>) {
const current = state(page)
page.listeners.forEach((listener) => listener(current))
}
function refresh<Resource>(page: Page<Resource>) {
page.state = page.port.state()
publish(page)
return state(page)
}
function state<Resource>(page: Page<Resource>): Browser.State {
assertAttached(page)
return {
url: page.state.url.slice(0, 16_384),
title: page.state.title.slice(0, 1_024),
loading: page.state.loading,
canGoBack: page.state.canGoBack,
canGoForward: page.state.canGoForward,
generation: page.document,
}
}
function disposePage<Resource>(page: Page<Resource>) {
if (page.disposal) return page.disposal
page.disposed = true
page.active?.abort()
page.listeners.clear()
invalidateRefs(page)
page.unsubscribe?.()
page.unsubscribe = undefined
page.port.stop()
page.disposal = Promise.resolve(page.port.dispose())
return page.disposal
}
function invalidateRefs<Resource>(page: Page<Resource>) {
if (page.snapshotObjectID) release(page, page.snapshotObjectID)
page.snapshotObjectID = undefined
page.refs.clear()
}
function release<Resource>(page: Page<Resource>, objectID: string) {
void Promise.resolve(
page.port.send({ method: "Runtime.releaseObject", params: { objectId: objectID } }),
).catch(() => undefined)
}
function resolveRef<Resource>(page: Page<Resource>, ref: Browser.Ref) {
if (!page.snapshotObjectID || !page.refs.has(ref)) {
throw browserError("stale_ref", "The element reference is stale. Call browser_snapshot again.")
}
return page.snapshotObjectID
}
function send<Resource>(page: Page<Resource>, command: ChromiumCommand, signal?: AbortSignal) {
return bounded(
() => page.port.send(command),
signal,
commandTimeout,
"The browser command timed out.",
).catch((error) => {
if (staleProtocolError(error)) {
throw browserError("stale_ref", "The element reference is stale. Call browser_snapshot again.")
}
throw error
})
}
function readSnapshot(input: unknown) {
const value = runtimeValue(input)
if (!record(value) || !Array.isArray(value.nodes) || value.nodes.length > snapshotLimit) {
throw browserError("internal", "Invalid browser snapshot response.")
}
if (!Number.isSafeInteger(value.nextRef) || Number(value.nextRef) < 0) {
throw browserError("internal", "Invalid browser snapshot response.")
}
const nodes = value.nodes.map((node) => {
if (!snapshotNode(node)) throw browserError("internal", "Invalid browser snapshot response.")
return node
})
return { nodes, nextRef: Number(value.nextRef) }
}
function snapshotNode(input: unknown): input is SnapshotNode {
if (!record(input)) return false
if (
typeof input.role !== "string" ||
!/^[a-zA-Z0-9_-]{1,40}$/.test(input.role) ||
typeof input.name !== "string" ||
typeof input.value !== "string" ||
!Number.isSafeInteger(input.depth) ||
Number(input.depth) < 0 ||
Number(input.depth) > 6
)
return false
if (input.token !== undefined && (typeof input.token !== "string" || !/^e[1-9][0-9]*$/.test(input.token))) return false
return true
}
function runtimeObjectID(input: unknown) {
if (!record(input) || !record(input.result) || typeof input.result.objectId !== "string") {
throw browserError("internal", "Browser page operation failed.")
}
return input.result.objectId
}
function runtimeValue(input: unknown) {
if (!record(input)) throw browserError("internal", "Browser page operation failed.")
if (input.exceptionDetails !== undefined) {
const details = record(input.exceptionDetails) ? input.exceptionDetails : undefined
const exception = details && record(details.exception) ? details.exception : undefined
const message =
(exception && typeof exception.description === "string" && exception.description) ||
(details && typeof details.text === "string" && details.text) ||
"Browser page operation failed."
if (staleProtocolError(message)) {
throw browserError("stale_ref", "The element reference is stale. Call browser_snapshot again.")
}
throw browserError("internal", message)
}
if (!record(input.result) || !("value" in input.result)) {
throw browserError("internal", "Browser page operation failed.")
}
return input.result.value
}
function readPoint(input: unknown) {
const value = runtimeValue(input)
if (!record(value) || typeof value.x !== "number" || typeof value.y !== "number") {
throw browserError("stale_ref", "The browser element has no clickable bounds.")
}
return { x: value.x, y: value.y }
}
function keyPair<Resource>(
page: Page<Resource>,
key: { readonly key: string; readonly code: string; readonly modifiers?: number; readonly windowsVirtualKeyCode?: number },
signal: AbortSignal,
) {
return inputPair(
() => send(page, { method: "Input.dispatchKeyEvent", params: { type: "keyDown", ...key } }, signal),
() => send(page, { method: "Input.dispatchKeyEvent", params: { type: "keyUp", ...key } }),
)
}
async function inputPair(down: () => Promise<unknown>, up: () => Promise<unknown>) {
try {
await down()
} finally {
await up()
}
}
function keyInfo(key: Browser.Key) {
const codes: Partial<Record<Browser.Key, number>> = {
Enter: 13,
Tab: 9,
Escape: 27,
Backspace: 8,
Delete: 46,
Space: 32,
}
const windowsVirtualKeyCode = codes[key]
return {
key: key === "Space" ? " " : key,
code: key,
...(windowsVirtualKeyCode ? { windowsVirtualKeyCode } : {}),
}
}
function assertDocument<Resource>(page: Page<Resource>, generation: number) {
if (page.document !== generation) {
throw browserError("stale_ref", "The browser page changed. Call browser_snapshot again.")
}
}
function assertAttached<Resource>(page: Page<Resource>) {
if (page.disposed) throw browserError("not_attached", "The browser page is no longer attached.")
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw browserError("aborted", "The browser action was aborted.")
}
function browserError(code: Browser.ErrorCode, message: string) {
return new BrowserDriverError(code, message.slice(0, 1_024))
}
function normalizeError(error: unknown) {
if (error instanceof BrowserDriverError) return error
return browserError("internal", error instanceof Error ? error.message : String(error))
}
function normalizeURL(input: string) {
const value = input.trim()
if (value.length > 16_384) throw browserError("invalid_url", "The browser URL is too long.")
if (!value || value === "about:blank") return "about:blank"
const candidate = /^(localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)(:\d+)?(?:\/|$)/i.test(value)
? `http://${value}`
: /^[a-z][a-z\d+.-]*:/i.test(value)
? value
: `https://${value}`
if (!URL.canParse(candidate)) throw browserError("invalid_url", "Enter a valid HTTP or HTTPS URL.")
const url = new URL(candidate)
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw browserError("invalid_url", "Only HTTP, HTTPS, and about:blank URLs are supported.")
}
if (url.href.length > 16_384) throw browserError("invalid_url", "The browser URL is too long.")
return url.href
}
function bounded<Result>(
run: () => PromiseLike<Result>,
signal: AbortSignal | undefined,
timeout: number,
timeoutMessage: string,
) {
throwIfAborted(signal)
const timedOut = AbortSignal.timeout(timeout)
const abort = signal ? AbortSignal.any([signal, timedOut]) : timedOut
return new Promise<Result>((resolve, reject) => {
abort.addEventListener(
"abort",
() =>
reject(
timedOut.aborted
? browserError("timeout", timeoutMessage)
: browserError("aborted", "The browser action was aborted."),
),
{ once: true },
)
Promise.resolve().then(run).then(resolve, reject)
})
}
function staleProtocolError(input: unknown) {
return /Could not find (node|object)|No node with given id|Node with given id does not belong|Could not push node|Could not compute box model|stale element/i.test(
input instanceof Error ? input.message : String(input),
)
}
function snapshotExpression(nextRef: number) {
return `(() => {
const interactive = new Set(["button","checkbox","combobox","link","menuitem","option","radio","searchbox","slider","spinbutton","switch","tab","textbox"])
const readable = new Set(["article","cell","columnheader","heading","img","list","listitem","p","region","row","rowheader","table"])
const roleFor = (element) => {
const explicit = element.getAttribute("role")
if (explicit) return explicit.slice(0, 100).split(/\\s+/)[0]
if (/^H[1-6]$/.test(element.tagName)) return "heading"
if (element.tagName === "INPUT") {
if (element.type === "checkbox") return "checkbox"
if (element.type === "radio") return "radio"
if (element.type === "range") return "slider"
if (element.type === "number") return "spinbutton"
if (element.type === "search") return "searchbox"
return "textbox"
}
return ({A:"link",ARTICLE:"article",BUTTON:"button",IMG:"img",LI:"listitem",OL:"list",P:"p",SELECT:"combobox",TABLE:"table",TD:"cell",TH:"columnheader",TR:"row",TEXTAREA:"textbox",UL:"list"})[element.tagName] || element.tagName.toLowerCase()
}
const clean = (value) => String(value || "").slice(0, 1000).replace(/\\s+/g, " ").trim().slice(0, 300)
const textFor = (element) => {
const queue = Array.from(element.childNodes).slice(0, 20)
const parts = []
let visited = 0
while (queue.length && visited++ < 20) {
const item = queue.shift()
if (item.nodeType === Node.TEXT_NODE) parts.push(item.nodeValue || "")
queue.push(...Array.from(item.childNodes).slice(0, Math.max(0, 20 - queue.length - visited)))
}
return parts.join(" ")
}
const nodes = []
const refs = Object.create(null)
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT)
let visited = 0
let ref = ${Math.max(0, Math.floor(nextRef))}
while (visited++ < ${snapshotLimit}) {
const element = walker.nextNode()
if (!element) break
if (element.hidden || element.getAttribute("aria-hidden") === "true" || (element.tagName === "INPUT" && element.type === "hidden")) continue
const role = clean(roleFor(element)).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40) || "node"
const isInteractive = interactive.has(role) || element.tabIndex >= 0
if (!isInteractive && !readable.has(role)) continue
const editable = ["INPUT","TEXTAREA","SELECT"].includes(element.tagName) || ["textbox","searchbox","combobox","spinbutton"].includes(role) || element.isContentEditable
const labelledBy = element.getAttribute("aria-labelledby")
const label = labelledBy && document.getElementById(labelledBy)
const token = isInteractive ? "e" + (++ref) : undefined
if (token) refs[token] = element
let depth = 0
for (let item = element.parentElement; item && depth < 6; item = item.parentElement) depth++
nodes.push({
token,
role,
name: clean(element.getAttribute("aria-label") || (label && textFor(label)) || element.alt || (editable ? "" : textFor(element))),
value: editable ? "" : clean(element.value),
depth,
checked: "checked" in element ? Boolean(element.checked) : undefined,
disabled: "disabled" in element ? Boolean(element.disabled) : undefined,
expanded: element.getAttribute("aria-expanded") === "true" ? true : element.getAttribute("aria-expanded") === "false" ? false : undefined,
selected: "selected" in element ? Boolean(element.selected) : undefined,
})
}
return { result: { nodes, nextRef: ref }, refs }
})()`
}
const fillFunction = `function(token) {
const element = this.refs[token]
if (!element || !element.isConnected) throw new Error("stale element")
const role = String(element.getAttribute("role") || "").split(/\\s+/, 1)[0]
const input = element.tagName === "INPUT" && !["button","checkbox","color","file","hidden","image","radio","range","reset","submit"].includes(String(element.type).toLowerCase())
const editable = input || element.tagName === "TEXTAREA" || element.isContentEditable || ["textbox","searchbox","combobox","spinbutton"].includes(role)
if (!editable || element.disabled || element.readOnly || element.getAttribute("aria-disabled") === "true" || element.getAttribute("aria-readonly") === "true") return false
element.focus()
return true
}`
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
-360
View File
@@ -1,360 +0,0 @@
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
import { Browser } from "@opencode-ai/schema/browser"
import { BrowserControl } from "@opencode-ai/schema/browser-control"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Schema } from "effect"
import WebSocket from "ws"
import type { ClientOptions } from "../../promise/generated/client.js"
import { browserDriverFactory, type BrowserDriver, type BrowserDriverInstance, type BrowserProxy } from "./driver.js"
import { createBrowserProxy } from "./proxy.js"
import { openBrowserTunnel, type BrowserTunnelEndpoint } from "./tunnel.js"
export interface BrowserRegisterOptions {
readonly sessionID: string
readonly open: () => Promise<void> | void
}
export interface BrowserAttachOptions<Resource> {
readonly driver: BrowserDriver<Resource>
readonly signal?: AbortSignal
}
export interface BrowserAttachment<Resource> extends AsyncDisposable {
readonly resource: Resource
readonly close: () => Promise<void>
}
export interface BrowserRegistration extends AsyncDisposable {
readonly attach: <Resource>(options: BrowserAttachOptions<Resource>) => Promise<BrowserAttachment<Resource>>
readonly close: () => Promise<void>
}
export interface BrowserClient {
readonly register: (options: BrowserRegisterOptions) => Promise<BrowserRegistration>
}
type ProxyServer = Awaited<ReturnType<typeof createBrowserProxy>>
type Attachment = {
readonly leaseID: Browser.LeaseID
readonly abort: AbortController
readonly attached: Promise<void>
readonly resolveAttached: () => void
readonly rejectAttached: (error: Error) => void
readonly externalSignal?: AbortSignal
readonly externalAbort: () => void
state?: Browser.State
execute?: BrowserDriverInstance<unknown>["execute"]
unsubscribe?: () => void
dispose?: () => Promise<void> | void
proxy?: ProxyServer
acknowledged: boolean
closed: boolean
closing?: Promise<void>
}
export function createBrowserClient(options: ClientOptions): BrowserClient {
const server = endpoint(options)
return { register: (input) => BrowserRegistrationControl.create(server, input) }
}
class BrowserRegistrationControl implements BrowserRegistration {
private readonly requests = new Map<BrowserControl.RequestID, AbortController>()
private readonly socket: WebSocket
private attachment?: Attachment
private resolveRegistered!: () => void
private rejectRegistered!: (error: Error) => void
private readonly registered: Promise<void>
private closed = false
private closing?: Promise<void>
static async create(server: BrowserTunnelEndpoint, options: BrowserRegisterOptions) {
if (!Schema.is(Session.ID)(options.sessionID))
throw new TypeError("Browser registration requires a valid Session ID")
if (typeof options.open !== "function") throw new TypeError("Browser registration requires an open callback")
if (process.versions.bun) {
const response = await (server.fetch ?? globalThis.fetch)(new URL("/api/health", server.url), {
headers: server.authorization ? { Authorization: server.authorization } : undefined,
signal: AbortSignal.timeout(10_000),
})
if (response.status === 401 || response.status === 403) {
throw new Error(`Browser control connection was rejected with HTTP ${response.status}`)
}
}
const registration = new BrowserRegistrationControl(server, Session.ID.make(options.sessionID), options.open)
await registration.registered
return registration
}
private constructor(
private readonly server: BrowserTunnelEndpoint,
private readonly sessionID: Session.ID,
private readonly open: BrowserRegisterOptions["open"],
) {
this.registered = new Promise<void>((resolve, reject) => {
this.resolveRegistered = resolve
this.rejectRegistered = reject
})
this.socket = new WebSocket(controlURL(server), BrowserControlProtocol.Subprotocol, {
...(server.authorization ? { headers: { Authorization: server.authorization } } : {}),
handshakeTimeout: 10_000,
maxPayload: BrowserControlProtocol.MaxMessageBytes,
perMessageDeflate: false,
followRedirects: false,
})
this.socket.once("open", () => this.send({ type: "browser.control.register", sessionID }))
this.socket.on("message", (data, binary) => void this.receive(data, binary))
this.socket.on("error", (error) => {
const status = /^Unexpected server response: (\d+)$/.exec(error.message)?.[1]
this.fail(new Error(status ? `Browser control connection was rejected with HTTP ${status}` : error.message))
})
if (!process.versions.bun) {
this.socket.on("unexpected-response", (_request, response) => {
response.resume()
this.fail(new Error(`Browser control connection was rejected with HTTP ${response.statusCode}`))
})
}
this.socket.on("close", () => this.fail(new Error("Browser control connection closed.")))
}
async attach<Resource>(input: BrowserAttachOptions<Resource>): Promise<BrowserAttachment<Resource>> {
if (this.closed) throw new Error("Browser registration is closed")
if (this.attachment) throw new Error("A browser is already attached to this registration")
if (input.signal?.aborted) throw abortError(input.signal, "Browser attachment was aborted")
let resolveAttached!: () => void
let rejectAttached!: (error: Error) => void
const attached = new Promise<void>((resolve, reject) => {
resolveAttached = resolve
rejectAttached = reject
})
const externalAbort = () =>
void this.closeAttachment(record, abortError(input.signal, "Browser attachment was aborted"))
const record: Attachment = {
leaseID: Browser.LeaseID.create(),
abort: new AbortController(),
attached,
resolveAttached,
rejectAttached,
externalSignal: input.signal,
externalAbort,
acknowledged: false,
closed: false,
}
this.attachment = record
input.signal?.addEventListener("abort", record.externalAbort, { once: true })
try {
record.proxy = await createBrowserProxy({
connect: async (target, signal) => {
await abortable(record.attached, signal)
return openBrowserTunnel({
endpoint: this.server,
sessionID: this.sessionID,
leaseID: record.leaseID,
target,
signal: AbortSignal.any([signal, record.abort.signal]),
})
},
})
const instance = await Promise.resolve(
browserDriverFactory(input.driver)({ proxy: exposedProxy(record.proxy), signal: record.abort.signal }),
)
if (!validDriver(instance)) throw new TypeError("Browser driver factory returned an invalid driver instance")
record.dispose = () => instance.dispose()
record.execute = (command, options) => instance.execute(command, options)
record.state = contractState(instance.state())
record.unsubscribe = instance.subscribe((state) => {
if (record.closed) return
record.state = contractState(state)
if (record.acknowledged)
this.send({ type: "browser.control.state", leaseID: record.leaseID, state: record.state })
})
this.send({ type: "browser.control.attach", leaseID: record.leaseID, state: record.state })
await abortable(record.attached, record.abort.signal)
record.acknowledged = true
this.send({ type: "browser.control.state", leaseID: record.leaseID, state: record.state })
const close = () => this.closeAttachment(record)
return Object.freeze({ resource: instance.resource, close, [Symbol.asyncDispose]: close })
} catch (error) {
await this.closeAttachment(record).catch(() => undefined)
throw error
}
}
close() {
if (this.closing) return this.closing
this.closed = true
const attachment = this.attachment
this.closing = (attachment ? this.closeAttachment(attachment) : Promise.resolve()).finally(() => {
this.requests.forEach((abort) => abort.abort())
this.requests.clear()
if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
else if (this.socket.readyState !== WebSocket.CLOSED) this.socket.terminate()
})
return this.closing
}
[Symbol.asyncDispose]() {
return this.close()
}
private closeAttachment(record: Attachment, reason = new Error("Browser attachment was closed")) {
if (record.closing) return record.closing
record.closed = true
record.externalSignal?.removeEventListener("abort", record.externalAbort)
record.abort.abort(reason)
record.rejectAttached(reason)
if (this.attachment === record) this.attachment = undefined
if (record.acknowledged) this.send({ type: "browser.control.detach", leaseID: record.leaseID })
record.closing = Promise.resolve()
.then(() => record.unsubscribe?.())
.then(() => record.dispose?.())
.then(() => record.proxy?.close())
return record.closing
}
private async receive(data: WebSocket.RawData, binary: boolean) {
if (binary) return this.protocolError()
const message = await Effect.runPromise(
BrowserControlProtocol.decodeFromServer(Buffer.from(rawData(data)).toString("utf8")),
).catch(() => undefined)
if (!message) return this.protocolError()
if (message.type === "browser.control.registered") {
this.resolveRegistered()
return
}
if (message.type === "browser.control.open") {
queueMicrotask(() => void Promise.resolve(this.open()).catch((error) => this.fail(asError(error))))
return
}
if (message.type === "browser.control.attached") {
if (this.attachment?.leaseID !== message.leaseID) return this.protocolError()
this.attachment.resolveAttached()
return
}
if (message.type === "browser.control.cancel") {
this.requests.get(message.requestID)?.abort(new Error("Browser command was cancelled"))
this.requests.delete(message.requestID)
return
}
void this.request(message)
}
private async request(message: Extract<BrowserControl.FromServer, { readonly type: "browser.control.request" }>) {
const record = this.attachment
if (!record?.acknowledged || record.leaseID !== message.leaseID || !record.execute) {
this.send({
type: "browser.control.response",
requestID: message.requestID,
leaseID: message.leaseID,
outcome: { type: "failure", code: "not_attached", message: "Browser is not attached." },
})
return
}
const abort = new AbortController()
this.requests.set(message.requestID, abort)
const outcome = await record.execute(message.command, { signal: abort.signal }).then(
(result): Browser.Outcome =>
Schema.is(Browser.Result)(result) && result.type === message.command.type
? { type: "success", result }
: { type: "failure", code: "protocol", message: "Browser driver returned an invalid result." },
(error): Browser.Outcome => driverFailure(error),
)
if (this.requests.get(message.requestID) !== abort) return
this.requests.delete(message.requestID)
this.send({ type: "browser.control.response", requestID: message.requestID, leaseID: message.leaseID, outcome })
}
private send(message: BrowserControl.FromClient) {
if (this.socket.readyState !== WebSocket.OPEN) return
this.socket.send(BrowserControlProtocol.encodeFromClient(message), (error) => {
if (error) this.fail(error)
})
}
private protocolError() {
this.fail(new Error("Invalid browser control message."))
}
private fail(error: Error) {
if (this.closed) return
this.rejectRegistered(error)
this.attachment?.rejectAttached(error)
void this.close()
}
}
function validDriver<Resource>(input: BrowserDriverInstance<Resource>): input is BrowserDriverInstance<Resource> {
return (
input !== null &&
typeof input === "object" &&
typeof input.state === "function" &&
typeof input.subscribe === "function" &&
typeof input.execute === "function" &&
typeof input.dispose === "function"
)
}
function exposedProxy(proxy: ProxyServer): BrowserProxy {
return Object.freeze({
url: proxy.url,
host: proxy.host,
port: proxy.port,
credentials: Object.freeze({ ...proxy.credentials }),
})
}
function contractState(state: Browser.State) {
if (!Schema.is(Browser.State)(state)) throw new TypeError("Browser driver returned an invalid state")
return Object.freeze({ ...state })
}
function driverFailure(error: unknown): Extract<Browser.Outcome, { readonly type: "failure" }> {
return {
type: "failure",
code:
error !== null && typeof error === "object" && "code" in error && Schema.is(Browser.ErrorCode)(error.code)
? error.code
: "internal",
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
}
}
function endpoint(options: ClientOptions): BrowserTunnelEndpoint {
const url = new URL(options.baseUrl)
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
}
const authorization = new Headers(options.headers).get("authorization") ?? undefined
return Object.freeze({ url: url.href, ...(authorization ? { authorization } : {}), fetch: options.fetch })
}
function controlURL(endpoint: BrowserTunnelEndpoint) {
const url = new URL(endpoint.url)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = BrowserControlProtocol.Path
url.search = ""
url.hash = ""
return url
}
function abortable<Result>(promise: Promise<Result>, signal: AbortSignal) {
if (signal.aborted) return Promise.reject(abortError(signal, "Browser operation was aborted"))
return new Promise<Result>((resolve, reject) => {
const abort = () => reject(abortError(signal, "Browser operation was aborted"))
signal.addEventListener("abort", abort, { once: true })
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
})
}
function abortError(signal: AbortSignal | undefined, message: string) {
return signal?.reason instanceof Error ? signal.reason : new Error(message)
}
function asError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
function rawData(data: WebSocket.RawData) {
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
@@ -1,53 +0,0 @@
import type { Browser } from "@opencode-ai/schema/browser"
import { chromiumDriver, type ChromiumDriver, type ChromiumPort } from "./chromium.js"
export interface BrowserProxy {
readonly url: string
readonly host: string
readonly port: number
readonly credentials: {
readonly username: string
readonly password: string
}
}
export interface BrowserDriverContext {
readonly proxy: BrowserProxy
readonly signal: AbortSignal
}
export interface BrowserDriverInstance<Resource> {
readonly resource: Resource
readonly state: () => Browser.State
readonly subscribe: (listener: (state: Browser.State) => void) => () => void
readonly execute: (command: Browser.Command, options: { readonly signal: AbortSignal }) => Promise<Browser.Result>
readonly dispose: () => Promise<void> | void
}
export type BrowserDriverFactory<Resource> = (
context: BrowserDriverContext,
) => Promise<BrowserDriverInstance<Resource>> | BrowserDriverInstance<Resource>
export class BrowserDriverError extends Error {
override readonly name = "BrowserDriverError"
constructor(
readonly code: Browser.ErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
export type BrowserDriver<Resource> = BrowserDriverFactory<Resource>
export const BrowserDriver = {
define<Resource>(create: BrowserDriverFactory<Resource>): BrowserDriver<Resource> {
return create
},
chromium<Resource>(
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
): ChromiumDriver<Resource> {
return chromiumDriver(create)
},
}
export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {
return driver
}
-260
View File
@@ -1,260 +0,0 @@
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
import { randomBytes, timingSafeEqual } from "node:crypto"
import {
Agent,
createServer,
request,
type IncomingHttpHeaders,
type IncomingMessage,
type ServerResponse,
} from "node:http"
import { Duplex } from "node:stream"
export type BrowserProxyConnector = (target: BrowserTunnel.Target, signal: AbortSignal) => Promise<Duplex>
/** Starts a loopback-only authenticated HTTP proxy backed exclusively by tunnel connections. */
export async function createBrowserProxy(input: { readonly connect: BrowserProxyConnector }) {
const username = randomBytes(16).toString("hex")
const password = randomBytes(32).toString("hex")
const expected = Buffer.from(`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`)
const clients = new Set<Duplex>()
const tunnels = new Set<Duplex>()
const pending = new Set<AbortController>()
let closed = false
const authorized = (value: string | undefined) => {
if (!value) return false
const actual = Buffer.from(value)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
const connect = async (target: BrowserTunnel.Target, signal?: AbortSignal) => {
if (closed) throw new Error("Browser proxy is closed")
const abort = new AbortController()
const cancel = () => abort.abort(signal?.reason)
signal?.addEventListener("abort", cancel, { once: true })
if (signal?.aborted) cancel()
pending.add(abort)
try {
const tunnel = await input.connect(target, abort.signal)
if (closed || abort.signal.aborted) {
tunnel.destroy()
throw abort.signal.reason ?? new Error("Browser proxy is closed")
}
tunnels.add(tunnel)
tunnel.once("close", () => tunnels.delete(tunnel))
tunnel.on("error", () => tunnel.destroy())
return tunnel
} finally {
pending.delete(abort)
signal?.removeEventListener("abort", cancel)
}
}
const server = createServer({ maxHeaderSize: 64 * 1_024 }, (incoming, response) => {
void forward(incoming, response, connect, authorized).catch(() => response.destroy())
})
server.requestTimeout = 30_000
server.headersTimeout = 10_000
server.keepAliveTimeout = 5_000
server.on("connection", (socket) => {
clients.add(socket)
socket.once("close", () => clients.delete(socket))
})
server.on("connect", (incoming, socket, head) => {
void (async () => {
if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
socket.end(
'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="OpenCode Browser Proxy"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
)
return
}
const destination = authority(incoming.url ?? "", 443)
if (!destination) {
socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
return
}
const abort = new AbortController()
const cancel = () => abort.abort(new Error("Browser proxy client closed"))
socket.once("close", cancel)
socket.pause()
const tunnel = await connect(destination, abort.signal)
socket.off("close", cancel)
if (socket.destroyed) {
tunnel.destroy()
return
}
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n")
if (head.byteLength) tunnel.write(head)
bridge(socket, tunnel)
socket.resume()
})().catch(() => {
if (!socket.destroyed) socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
})
})
server.on("error", () => undefined)
server.on("clientError", (_error, socket) => {
if (!socket.destroyed) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n")
})
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error)
server.once("error", onError)
server.listen(0, "127.0.0.1", () => {
server.off("error", onError)
resolve()
})
})
const address = server.address()
if (!address || typeof address === "string") throw new Error("Browser proxy did not bind a TCP address")
let closing: Promise<void> | undefined
return {
url: `http://127.0.0.1:${address.port}`,
host: "127.0.0.1",
port: address.port,
credentials: { username, password },
close() {
if (closing) return closing
closed = true
pending.forEach((abort) => abort.abort())
tunnels.forEach((tunnel) => tunnel.destroy())
clients.forEach((client) => client.destroy())
closing = new Promise<void>((resolve) => server.close(() => resolve()))
return closing
},
}
}
async function forward(
incoming: IncomingMessage,
response: ServerResponse,
connect: (target: BrowserTunnel.Target, signal?: AbortSignal) => Promise<Duplex>,
authorized: (header: string | undefined) => boolean,
) {
if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
response.writeHead(407, { "Proxy-Authenticate": 'Basic realm="OpenCode Browser Proxy"' })
response.end()
return
}
const url = parseURL(incoming.url)
if (!url || url.protocol !== "http:" || !url.hostname || url.username || url.password) {
response.writeHead(400)
response.end()
return
}
const port = url.port ? Number(url.port) : 80
const abort = new AbortController()
let tunnel: Duplex | undefined
let agent: Agent | undefined
const cancel = () => {
abort.abort(new Error("Browser proxy client closed"))
tunnel?.destroy()
}
incoming.once("aborted", cancel)
response.once("close", cancel)
try {
tunnel = await connect(target(normalizeHostname(url.hostname), port), abort.signal)
const headers = forwardedHeaders(incoming.headers)
headers.host = url.host
headers.connection = "close"
agent = new Agent({ keepAlive: false, maxSockets: 1 })
const connection = tunnel
agent.createConnection = () => connection
await new Promise<void>((resolve, reject) => {
const upstream = request(
{
agent,
hostname: url.hostname,
port,
path: `${url.pathname}${url.search}`,
method: incoming.method,
headers,
signal: abort.signal,
},
(result) => {
const headers = forwardedHeaders(result.headers)
headers.connection = "close"
response.writeHead(result.statusCode ?? 502, result.statusMessage, headers)
result.once("error", reject)
response.once("finish", resolve)
result.pipe(response)
},
)
upstream.once("error", reject)
incoming.pipe(upstream)
})
} finally {
incoming.off("aborted", cancel)
response.off("close", cancel)
agent?.destroy()
tunnel?.destroy()
}
}
function forwardedHeaders(input: IncomingHttpHeaders) {
const headers = { ...input }
singleHeader(headers.connection)
?.split(",")
.map((value) => value.trim().toLowerCase())
.forEach((name) => delete headers[name])
;[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
].forEach((name) => delete headers[name])
return headers
}
function bridge(client: Duplex, tunnel: Duplex) {
client.on("error", () => tunnel.destroy())
tunnel.on("error", () => client.destroy())
client.once("close", () => tunnel.destroy())
tunnel.once("close", () => client.destroy())
client.pipe(tunnel)
tunnel.pipe(client)
}
function authority(value: string, defaultPort: number) {
const bracket = /^\[([^\]]+)](?::([0-9]+))?$/.exec(value)
if (bracket) return validAuthority(bracket[1], bracket[2], defaultPort)
const separator = value.lastIndexOf(":")
if (separator < 0) return validAuthority(value, undefined, defaultPort)
if (value.slice(0, separator).includes(":")) return undefined
return validAuthority(value.slice(0, separator), value.slice(separator + 1), defaultPort)
}
function validAuthority(host: string, value: string | undefined, defaultPort: number) {
if (!host || (value !== undefined && !/^[0-9]+$/.test(value))) return undefined
const port = value === undefined ? defaultPort : Number(value)
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return undefined
try {
return target(host, port)
} catch {
return undefined
}
}
function target(host: string, port: number): BrowserTunnel.Target {
return { host: BrowserTunnel.Host.make(host), port: BrowserTunnel.Port.make(port) }
}
function parseURL(value: string | undefined) {
try {
return new URL(value ?? "")
} catch {
return undefined
}
}
function normalizeHostname(hostname: string) {
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname
}
function singleHeader(value: string | ReadonlyArray<string> | undefined) {
return typeof value === "string" ? value : undefined
}
-208
View File
@@ -1,208 +0,0 @@
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
import { Browser } from "@opencode-ai/schema/browser"
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
import { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { Duplex } from "node:stream"
import WebSocket from "ws"
export type BrowserTunnelEndpoint = {
readonly url: string
readonly authorization?: string
readonly fetch?: typeof globalThis.fetch
}
export type BrowserTunnelOpen = {
readonly endpoint: BrowserTunnelEndpoint
readonly sessionID: Session.ID
readonly leaseID: Browser.LeaseID
readonly target: BrowserTunnel.Target
readonly signal?: AbortSignal
}
export class BrowserTunnelError extends Error {
constructor(
readonly code: BrowserTunnel.OpenErrorCode | "transport",
message: string,
) {
super(message)
this.name = "BrowserTunnelError"
}
}
/** Opens one WebSocket whose binary messages are the bytes of one TCP connection. */
export async function openBrowserTunnel(input: BrowserTunnelOpen): Promise<Duplex> {
const tunnel = new BrowserTunnelStream(input)
await tunnel.opened
return tunnel
}
class BrowserTunnelStream extends Duplex {
readonly connecting = false
readonly opened: Promise<void>
private resolveOpened!: () => void
private rejectOpened!: (error: Error) => void
private readonly socket: WebSocket
private readonly signal?: AbortSignal
private state: "opening" | "open" | "closed" = "opening"
private paused = false
private timer?: ReturnType<typeof setTimeout>
constructor(input: BrowserTunnelOpen) {
super()
this.opened = new Promise<void>((resolve, reject) => {
this.resolveOpened = resolve
this.rejectOpened = reject
})
this.on("error", () => undefined)
this.signal = input.signal
this.socket = new WebSocket(endpointURL(input.endpoint), BrowserTunnelProtocol.Subprotocol, {
...(input.endpoint.authorization ? { headers: { Authorization: input.endpoint.authorization } } : {}),
handshakeTimeout: 10_000,
maxPayload: BrowserTunnelProtocol.MaxFrameBytes,
perMessageDeflate: false,
followRedirects: false,
})
this.timer = setTimeout(
() => this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake timed out.")),
15_000,
)
this.timer.unref()
this.socket.once("open", () => {
this.socket.send(
BrowserTunnelProtocol.encodeFromClient({
type: "browser.tunnel.open",
sessionID: input.sessionID,
leaseID: input.leaseID,
target: input.target,
}),
)
})
this.socket.on("message", (data, binary) => void this.receive(data, binary))
this.socket.on("error", (error) => this.fail(new BrowserTunnelError("transport", error.message)))
this.socket.on("close", () => {
if (this.state === "opening")
this.fail(new BrowserTunnelError("transport", "Browser tunnel closed while opening."))
if (this.state !== "open") return
this.state = "closed"
this.push(null)
this.destroy()
})
this.signal?.addEventListener("abort", this.onAbort, { once: true })
if (this.signal?.aborted) this.onAbort()
}
override _read() {
if (!this.paused) return
this.paused = false
this.socket.resume()
}
override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
if (this.state !== "open") {
callback(new BrowserTunnelError("transport", "Browser tunnel is not writable."))
return
}
const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk
const frames = Array.from(
{ length: Math.ceil(data.byteLength / BrowserTunnelProtocol.MaxFrameBytes) },
(_, index) =>
data.subarray(index * BrowserTunnelProtocol.MaxFrameBytes, (index + 1) * BrowserTunnelProtocol.MaxFrameBytes),
)
const send = (index: number) => {
if (index === frames.length) {
callback()
return
}
this.socket.send(frames[index], { binary: true }, (error) => {
if (error) callback(error)
else send(index + 1)
})
}
send(0)
}
override _final(callback: (error?: Error | null) => void) {
if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
callback()
}
override _destroy(error: Error | null, callback: (error?: Error | null) => void) {
if (this.timer) clearTimeout(this.timer)
this.signal?.removeEventListener("abort", this.onAbort)
if (this.state === "opening" && error) this.rejectOpened(error)
this.state = "closed"
if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
else if (this.socket.readyState !== WebSocket.CLOSED) this.socket.terminate()
callback(error)
}
setKeepAlive() {
return this
}
setNoDelay() {
return this
}
setTimeout(_timeout: number, callback?: () => void) {
if (callback) this.once("timeout", callback)
return this
}
ref() {
return this
}
unref() {
return this
}
private async receive(data: WebSocket.RawData, binary: boolean) {
if (this.state === "opening") {
if (binary) return this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake must be text."))
const message = await Effect.runPromise(
BrowserTunnelProtocol.decodeFromServer(Buffer.from(rawData(data)).toString("utf8")),
).catch(() => undefined)
if (!message) return this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake is invalid."))
if (message.type === "browser.tunnel.rejected")
return this.fail(new BrowserTunnelError(message.code, message.message))
this.state = "open"
if (this.timer) clearTimeout(this.timer)
this.resolveOpened()
return
}
if (this.state !== "open" || !binary)
return this.fail(new BrowserTunnelError("transport", "Browser tunnel payload is invalid."))
if (!this.push(rawData(data))) {
this.paused = true
this.socket.pause()
}
}
private fail(error: BrowserTunnelError) {
if (this.state === "closed") return
if (this.state === "opening") this.rejectOpened(error)
this.destroy(error)
}
private readonly onAbort = () => this.fail(new BrowserTunnelError("transport", "Browser tunnel was cancelled."))
}
function endpointURL(endpoint: BrowserTunnelEndpoint) {
const url = new URL(endpoint.url)
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
}
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.pathname = BrowserTunnelProtocol.Path
url.search = ""
url.hash = ""
return url
}
function rawData(data: WebSocket.RawData) {
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
-13
View File
@@ -1,13 +0,0 @@
import { OpenCode } from "../promise/generated/index.js"
import { createBrowserClient } from "./browser/client.js"
export type ClientOptions = OpenCode.ClientOptions
export type RequestOptions = OpenCode.RequestOptions
/** Creates the Promise client with Node-only browser attachment support. */
export function make(options: ClientOptions) {
return {
...OpenCode.make(options),
browser: createBrowserClient(options),
}
}
-35
View File
@@ -1,35 +0,0 @@
export { ClientError, type ClientErrorReason } from "../promise/generated/client-error.js"
export * from "../promise/generated/types.js"
export type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "../promise/api.js"
export * as OpenCode from "./client.js"
export { Browser } from "@opencode-ai/schema/browser"
export { BrowserDriver, BrowserDriverError } from "./browser/driver.js"
export type {
BrowserDriverContext,
BrowserDriverFactory,
BrowserDriverInstance,
BrowserProxy,
} from "./browser/driver.js"
export type { ChromiumController, ChromiumDriver, ChromiumPort } from "./browser/chromium.js"
export type {
BrowserAttachment,
BrowserAttachOptions,
BrowserClient,
BrowserRegistration,
BrowserRegisterOptions,
} from "./browser/client.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js"
export type OpenCodeClient = ReturnType<typeof import("./client.js").make>
@@ -510,7 +510,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
body: { messageID: input["messageID"] },
body: { boundary: input["boundary"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
+51 -51
View File
@@ -14,6 +14,8 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
export type TokenUsageInfo = {
@@ -43,13 +45,7 @@ export type PromptMention = { start: number; end: number; text: string }
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionPendingCompaction = {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
type: "compaction"
}
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type SessionMessageAgentSelected = {
id: string
@@ -283,7 +279,7 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
export type ProjectCurrent = { id: string; directory: string }
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
@@ -628,7 +624,7 @@ export type SessionForked = {
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: { sessionID: string; parentID: string; parentSeq: number; from?: string }
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInputPromoted = {
@@ -1210,7 +1206,6 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type SessionPendingSynthetic = {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
@@ -1435,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
export type Project = {
id: string
worktree: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
@@ -1755,7 +1750,7 @@ export type PermissionRuleset = Array<PermissionRule>
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; messageID?: string }
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
@@ -1966,7 +1961,6 @@ export type AgentInfo = {
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionPendingUser = {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
@@ -2521,7 +2515,11 @@ export type LocationGetInput = {
}["location"]
}
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
export type LocationGetOutput = {
directory: string
workspaceID?: string
project: { id: string; directory: string; canonical: string }
}
export type AgentListInput = {
readonly location?: {
@@ -2530,7 +2528,7 @@ export type AgentListInput = {
}
export type AgentListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<AgentInfo>
}
@@ -2542,7 +2540,7 @@ export type AgentGetInput = {
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: AgentInfo
}
@@ -2553,7 +2551,7 @@ export type PluginListInput = {
}
export type PluginListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
@@ -2702,7 +2700,9 @@ export type SessionRemoveOutput = void
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: { readonly messageID?: string | undefined }["messageID"]
readonly boundary: {
readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" }
}["boundary"]
}
export type SessionForkOutput = { data: SessionInfo }["data"]
@@ -3235,7 +3235,7 @@ export type ModelListInput = {
}
export type ModelListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ModelInfo>
}
@@ -3246,7 +3246,7 @@ export type ModelDefaultInput = {
}
export type ModelDefaultOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ModelInfo | null
}
@@ -3273,7 +3273,7 @@ export type ProviderListInput = {
}
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ProviderInfo>
}
@@ -3285,7 +3285,7 @@ export type ProviderGetInput = {
}
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ProviderInfo
}
@@ -3296,7 +3296,7 @@ export type IntegrationListInput = {
}
export type IntegrationListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<IntegrationInfo>
}
@@ -3308,7 +3308,7 @@ export type IntegrationGetInput = {
}
export type IntegrationGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationInfo | null
}
@@ -3355,7 +3355,7 @@ export type IntegrationOauthConnectInput = {
}
export type IntegrationOauthConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: {
attemptID: string
url: string
@@ -3374,7 +3374,7 @@ export type IntegrationOauthStatusInput = {
}
export type IntegrationOauthStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationAttemptStatus
}
@@ -3409,7 +3409,7 @@ export type IntegrationCommandConnectInput = {
}
export type IntegrationCommandConnectOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttempt
}
@@ -3422,7 +3422,7 @@ export type IntegrationCommandStatusInput = {
}
export type IntegrationCommandStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: IntegrationCommandAttemptStatus
}
@@ -3443,7 +3443,7 @@ export type McpListInput = {
}
export type McpListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<McpServer>
}
@@ -3532,7 +3532,7 @@ export type McpResourceCatalogInput = {
}
export type McpResourceCatalogOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: McpResourceCatalog
}
@@ -3581,7 +3581,7 @@ export type FormRequestListInput = {
}
export type FormRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FormInfo>
}
@@ -4437,7 +4437,7 @@ export type PermissionRequestListInput = {
}
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PermissionRequest>
}
@@ -4559,7 +4559,7 @@ export type FileListInput = {
}
export type FileListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4591,7 +4591,7 @@ export type FileFindInput = {
}
export type FileFindOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileSystemEntry>
}
@@ -4602,7 +4602,7 @@ export type CommandListInput = {
}
export type CommandListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<CommandInfo>
}
@@ -4613,7 +4613,7 @@ export type SkillListInput = {
}
export type SkillListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<SkillInfo>
}
@@ -4626,7 +4626,7 @@ export type PtyListInput = {
}
export type PtyListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<Pty>
}
@@ -4672,7 +4672,7 @@ export type PtyCreateInput = {
}
export type PtyCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4684,7 +4684,7 @@ export type PtyGetInput = {
}
export type PtyGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4701,7 +4701,7 @@ export type PtyUpdateInput = {
}
export type PtyUpdateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Pty
}
@@ -4721,7 +4721,7 @@ export type ShellListInput = {
}
export type ShellListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ShellInfo1>
}
@@ -4756,7 +4756,7 @@ export type ShellCreateInput = {
}
export type ShellCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4768,7 +4768,7 @@ export type ShellGetInput = {
}
export type ShellGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4781,7 +4781,7 @@ export type ShellTimeoutInput = {
}
export type ShellTimeoutOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}
@@ -4805,7 +4805,7 @@ export type ShellOutputInput = {
}
export type ShellOutputOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { output: string; cursor: number; size: number; truncated: boolean }
}
@@ -4825,7 +4825,7 @@ export type QuestionRequestListInput = {
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<QuestionRequest>
}
@@ -4855,7 +4855,7 @@ export type ReferenceListInput = {
}
export type ReferenceListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<ReferenceInfo>
}
@@ -4898,7 +4898,7 @@ export type VcsStatusInput = {
}
export type VcsStatusOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<VcsFileStatus>
}
@@ -4921,7 +4921,7 @@ export type VcsDiffInput = {
}
export type VcsDiffOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<FileDiffInfo>
}
@@ -4942,7 +4942,7 @@ export type WebsearchProvidersInput = {
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<WebSearchProvider>
}
@@ -4955,6 +4955,6 @@ export type WebsearchQueryInput = {
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
-2
View File
@@ -268,7 +268,6 @@ const session = {
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
type: "user",
@@ -281,7 +280,6 @@ const admission = {
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
+1 -12
View File
@@ -5,7 +5,6 @@ import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
const ws = realpathSync(resolve(import.meta.dir, "../node_modules/ws"))
const schema = resolve(import.meta.dir, "../../schema")
const protocol = resolve(import.meta.dir, "../../protocol")
const core = resolve(import.meta.dir, "../../core")
@@ -18,7 +17,6 @@ describe("public import boundaries", () => {
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, ws)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
@@ -30,15 +28,6 @@ describe("public import boundaries", () => {
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
const node = await bundleInputs("@opencode-ai/client/node", "node")
expect(within(node, effect).length).toBeGreaterThan(0)
expect(within(node, schema).length).toBeGreaterThan(0)
expect(within(node, protocol).length).toBeGreaterThan(0)
expect(within(node, ws).length).toBeGreaterThan(0)
expect(within(node, core)).toEqual([])
expect(within(node, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService, effect)).toEqual([])
@@ -56,7 +45,7 @@ describe("public import boundaries", () => {
})
})
async function bundleInputs(specifier: string, target: "browser" | "bun" | "node") {
async function bundleInputs(specifier: string, target: "browser" | "bun") {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
@@ -1,149 +0,0 @@
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
import { BrowserControl } from "@opencode-ai/schema/browser-control"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { once } from "node:events"
import { createServer } from "node:http"
import WebSocket, { WebSocketServer } from "ws"
import { Browser, BrowserDriver, OpenCode } from "@opencode-ai/client/node"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 1,
}
describe("Node browser client", () => {
test("registers one Session and handles attach, command, detach, and open", async () => {
const server = await controlServer()
let opened = 0
let disposed = 0
const client = OpenCode.make({ baseUrl: server.url })
try {
const registering = client.browser.register({ sessionID: "ses_node_browser", open: () => opened++ })
const socket = await server.connected
const next = reader(socket)
expect(await next()).toEqual({ type: "browser.control.register", sessionID: "ses_node_browser" })
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.registered" }))
const registration = await registering
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.open" }))
await waitFor(() => opened === 1)
expect(opened).toBe(1)
const attaching = registration.attach({
driver: BrowserDriver.define(({ proxy }) => ({
resource: proxy,
state: () => state,
subscribe: () => () => undefined,
execute: async () => ({ type: "snapshot", state, format: "opencode.semantic.v1", content: "snapshot" }),
dispose: () => disposed++,
})),
})
const attach = await next()
if (attach.type !== "browser.control.attach") throw new Error("expected browser attach")
expect(attach.state).toEqual(state)
socket.send(
BrowserControlProtocol.encodeFromServer({ type: "browser.control.attached", leaseID: attach.leaseID }),
)
const attachment = await attaching
expect(attachment.resource.url.startsWith("http://127.0.0.1:")).toBe(true)
expect((await next()).type).toBe("browser.control.state")
const requestID = BrowserControl.RequestID.create()
socket.send(
BrowserControlProtocol.encodeFromServer({
type: "browser.control.request",
requestID,
leaseID: attach.leaseID,
command: { type: "snapshot", generation: 1 },
}),
)
expect(await next()).toMatchObject({
type: "browser.control.response",
requestID,
outcome: { type: "success", result: { type: "snapshot", content: "snapshot" } },
})
await attachment.close()
expect(await next()).toEqual({ type: "browser.control.detach", leaseID: attach.leaseID })
expect(socket.readyState).toBe(WebSocket.OPEN)
expect(disposed).toBe(1)
const closed = once(socket, "close")
await registration.close()
await closed
} finally {
await server.close()
}
})
})
async function controlServer(authorization?: string) {
const http = createServer((request, response) => {
response.statusCode = request.headers.authorization === authorization ? 200 : 401
response.end()
})
const webSockets = new WebSocketServer({ noServer: true })
let resolveConnected!: (socket: WebSocket) => void
const connected = new Promise<WebSocket>((resolve) => {
resolveConnected = resolve
})
webSockets.once("connection", resolveConnected)
http.on("upgrade", (request, socket, head) => {
if (
request.url !== BrowserControlProtocol.Path ||
request.headers.authorization !== authorization ||
request.headers["sec-websocket-protocol"] !== BrowserControlProtocol.Subprotocol
) {
socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n")
return
}
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
})
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
const address = http.address()
if (!address || typeof address === "string") throw new Error("control server did not bind")
return {
connected,
url: `http://127.0.0.1:${address.port}`,
async close() {
webSockets.clients.forEach((socket) => socket.terminate())
webSockets.close()
http.closeAllConnections()
await new Promise<void>((resolve) => http.close(() => resolve()))
},
}
}
function reader(socket: WebSocket) {
const queued: WebSocket.RawData[] = []
const waiting: Array<(data: WebSocket.RawData) => void> = []
socket.on("message", (data, binary) => {
if (binary) throw new Error("expected text control message")
const resolve = waiting.shift()
if (resolve) resolve(data)
else queued.push(data)
})
return async () => {
const data = queued.shift() ?? (await new Promise<WebSocket.RawData>((resolve) => waiting.push(resolve)))
return Effect.runPromise(BrowserControlProtocol.decodeFromClient(Buffer.from(rawData(data)).toString("utf8")))
}
}
function rawData(data: WebSocket.RawData) {
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
if (check()) return
await Bun.sleep(5)
}
throw new Error("timed out waiting for browser client")
}
@@ -1,85 +0,0 @@
import { Browser, BrowserDriver, type BrowserDriverContext, type ChromiumPort } from "@opencode-ai/client/node"
import { describe, expect, test } from "bun:test"
type Port = ChromiumPort<{ readonly name: string }>
type Command = Parameters<Port["send"]>[0]
type Listener = Parameters<Port["subscribe"]>[0]
describe("Chromium browser driver", () => {
test("snapshots accessibility refs and invalidates them with the document generation", async () => {
const port = new FakePort()
const instance = await BrowserDriver.chromium(() => port)({
proxy: { url: "http://127.0.0.1:1", host: "127.0.0.1", port: 1, credentials: { username: "u", password: "p" } },
signal: new AbortController().signal,
} satisfies BrowserDriverContext)
const execute = (command: Browser.Command) => instance.execute(command, { signal: new AbortController().signal })
const snapshot = await execute({ type: "snapshot", generation: 0 })
expect(snapshot).toMatchObject({
type: "snapshot",
content: expect.stringContaining('e1 [button] "Save" disabled=false'),
})
expect(port.expression).toContain("while (visited++ < 500)")
expect(port.expression).not.toContain("textContent")
await execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 })
port.emit()
expect(instance.resource.state().generation).toBe(1)
expect(port.commands.some((command) => command.method === "Runtime.releaseObject")).toBe(true)
await expect(execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 1 })).rejects.toMatchObject({
code: "stale_ref",
})
await instance.resource.dispose()
})
})
class FakePort implements Port {
readonly resource = { name: "chromium" }
readonly listeners = new Set<Listener>()
readonly commands: Command[] = []
current = { url: "https://example.com/", title: "Example", loading: false, canGoBack: false, canGoForward: false }
expression = ""
state() {
return this.current
}
subscribe(listener: Listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
navigate() {}
back() {}
forward() {}
reload() {}
stop() {}
send(command: Command) {
this.commands.push(command)
if (command.method === "Runtime.evaluate") {
this.expression = command.params.expression
return Promise.resolve({ result: { objectId: "snapshot" } })
}
if (command.method !== "Runtime.callFunctionOn") return Promise.resolve({})
if (command.params.functionDeclaration === "function() { return this.result }") {
return Promise.resolve({
result: {
value: {
nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }],
nextRef: 1,
},
},
})
}
return Promise.resolve({ result: { value: { x: 25, y: 40 } } })
}
viewport() {
return { width: 800, height: 600 }
}
screenshot() {
return Promise.resolve({ data: new Uint8Array(), width: 800, height: 600 })
}
dispose() {}
emit() {
this.current = { ...this.current, url: "https://next.example/" }
this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged: true }))
}
}
-133
View File
@@ -1,133 +0,0 @@
import { expect, test } from "bun:test"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { join, relative, resolve } from "node:path"
import { pathToFileURL } from "node:url"
const directory = resolve(import.meta.dir, "../..")
test("built Node entrypoint imports and exposes browser registration in Node", async () => {
await buildClient()
const output = await Bun.file(join(directory, "dist/node/index.js")).text()
expect(output).not.toMatch(/(?:from\s+|import\s*)["']\.\.?\//)
const temporary = await mkdtemp(join(import.meta.dir, ".node-package-"))
try {
await Bun.write(join(temporary, "index.mjs"), output)
await stageWorkspaceDependencies(temporary)
const child = Bun.spawn(
["node", "--input-type=module", "-e", nodeScenario(pathToFileURL(join(temporary, "index.mjs")).href)],
{ cwd: temporary, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stderr || stdout)
expect(stdout.trim()).toBe("ok")
} finally {
await rm(temporary, { recursive: true, force: true })
}
}, 60_000)
async function buildClient() {
const child = Bun.spawn([process.execPath, "run", "build"], {
cwd: directory,
stdout: "pipe",
stderr: "pipe",
})
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
}
async function stageWorkspaceDependencies(temporary: string) {
const schema = join(temporary, "node_modules/@opencode-ai/schema")
const protocol = join(temporary, "node_modules/@opencode-ai/protocol")
await Promise.all([mkdir(schema, { recursive: true }), mkdir(protocol, { recursive: true })])
const schemaEntry = join(temporary, "schema.ts")
const protocolEntry = join(temporary, "protocol.ts")
await Promise.all([
Bun.write(
schemaEntry,
[
`export { Browser } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser.ts")))}`,
`export { BrowserControl } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-control.ts")))}`,
`export { BrowserTunnel } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-tunnel.ts")))}`,
`export { Session } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/session.ts")))}`,
].join("\n"),
),
Bun.write(
protocolEntry,
[
`export { BrowserControlProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-control.ts")))}`,
`export { BrowserTunnelProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-tunnel.ts")))}`,
].join("\n"),
),
])
const [schemaBuild, protocolBuild] = await Promise.all([
Bun.build({
entrypoints: [schemaEntry],
outdir: schema,
naming: "index.js",
target: "node",
format: "esm",
packages: "bundle",
}),
Bun.build({
entrypoints: [protocolEntry],
outdir: protocol,
naming: "index.js",
target: "node",
format: "esm",
packages: "bundle",
}),
])
if (!schemaBuild.success) throw new Error(schemaBuild.logs.map((log) => log.message).join("\n"))
if (!protocolBuild.success) throw new Error(protocolBuild.logs.map((log) => log.message).join("\n"))
await Promise.all([
Bun.write(
join(schema, "package.json"),
JSON.stringify({
type: "module",
exports: {
"./browser": "./index.js",
"./browser-control": "./index.js",
"./browser-tunnel": "./index.js",
"./session": "./index.js",
},
}),
),
Bun.write(
join(protocol, "package.json"),
JSON.stringify({
type: "module",
exports: {
"./browser-control": "./index.js",
"./browser-tunnel": "./index.js",
},
}),
),
])
}
function importPath(from: string, to: string) {
const path = relative(from, to).replaceAll("\\", "/")
return path.startsWith(".") ? path : `./${path}`
}
function nodeScenario(moduleURL: string) {
return `const sdk = await import(${JSON.stringify(moduleURL)})
if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make")
if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define")
if (typeof sdk.BrowserDriver.chromium !== "function") throw new Error("Missing BrowserDriver.chromium")
if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError")
if (!sdk.Browser.State) throw new Error("Missing canonical Browser export")
const client = sdk.OpenCode.make({ baseUrl: "http://127.0.0.1:1" })
if (typeof client.browser.register !== "function") throw new Error("Missing browser.register")
console.log("ok")`
}
-4
View File
@@ -304,7 +304,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
admittedSeq: 3,
id: "msg_pending",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
@@ -547,7 +546,6 @@ const session = {
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
type: "user",
@@ -559,7 +557,6 @@ const admission = {
const syntheticAdmission = {
data: {
admittedSeq: 1,
id: "msg_synthetic",
sessionID: "ses_test",
type: "synthetic",
@@ -572,7 +569,6 @@ const syntheticAdmission = {
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
@@ -1,47 +0,0 @@
import {
Browser,
BrowserDriver,
BrowserDriverError,
OpenCode,
type BrowserAttachment,
type BrowserRegistration,
type ChromiumController,
type ChromiumDriver,
type ChromiumPort,
} from "@opencode-ai/client/node"
const state: Browser.State = {
url: "about:blank",
title: "",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 0,
}
const factory: BrowserDriver<{ readonly proxyURL: string }> = (context) => ({
resource: { proxyURL: context.proxy.url },
state: () => state,
subscribe: () => () => undefined,
execute: async (_command, options) => {
throw new BrowserDriverError(options.signal.aborted ? "aborted" : "internal", "Command unavailable")
},
dispose: () => undefined,
})
const driver = BrowserDriver.define(factory)
declare const port: ChromiumPort<{ readonly page: true }>
const chromium: ChromiumDriver<{ readonly page: true }> = BrowserDriver.chromium(() => port)
declare const client: ReturnType<typeof OpenCode.make>
const registration: Promise<BrowserRegistration> = client.browser.register({
sessionID: "ses_type_fixture",
open: () => undefined,
})
void registration.then((handle) => {
const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = handle.attach({ driver })
const chromiumAttachment: Promise<BrowserAttachment<ChromiumController<{ readonly page: true }>>> = handle.attach({
driver: chromium,
})
void attachment
void chromiumAttachment
})

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