Compare commits

..

2 Commits

Author SHA1 Message Date
Brendan Allan 3aaff6ffe2 refactor(app): establish v2 session controller 2026-07-29 14:25:07 +08:00
Brendan Allan 99cae9766b refactor(app): extract session timeline controller 2026-07-29 14:24:28 +08:00
415 changed files with 7597 additions and 11981 deletions
+10 -7
View File
@@ -2,12 +2,15 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
export default { export default {
id: "test.tui-discovery-smoke", id: "test.tui-discovery-smoke",
setup(_context: Context) { setup(context: Context) {
// context.ui.toast.show({ const timer = setTimeout(() => {
// title: "TUI plugin discovery works", context.ui.toast.show({
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts", title: "TUI plugin discovery works",
// variant: "success", message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
// duration: 30_000, variant: "success",
// }) duration: 30_000,
})
}, 1_000)
return () => clearTimeout(timer)
}, },
} }
+6 -21
View File
@@ -67,7 +67,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz", "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
@@ -88,6 +87,7 @@
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:", "@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -143,10 +143,7 @@
"open": "10.1.2", "open": "10.1.2",
"semver": "catalog:", "semver": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3", "uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0", "ws": "8.21.0",
}, },
"devDependencies": { "devDependencies": {
@@ -383,6 +380,7 @@
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*", "@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -403,11 +401,9 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"tree-sitter-bash": "0.25.0", "semver": "^7.6.3",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1", "venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1", "which": "6.0.1",
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -424,6 +420,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
@@ -517,7 +514,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
@@ -604,7 +600,6 @@
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
@@ -616,14 +611,12 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5", "@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5", "@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5", "@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0", "solid-js": ">=1.9.0",
}, },
"optionalPeers": [ "optionalPeers": [
"@opencode-ai/theme",
"@opentui/core", "@opentui/core",
"@opentui/keymap", "@opentui/keymap",
"@opentui/solid", "@opentui/solid",
@@ -1077,11 +1070,9 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"tree-sitter-powershell",
"protobufjs", "protobufjs",
"electron", "electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash",
], ],
"patchedDependencies": { "patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -4078,10 +4069,10 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"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=="], "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=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "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=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -5640,10 +5631,6 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], "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=="], "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6866,8 +6853,6 @@
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "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/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=="], "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
@@ -1,118 +0,0 @@
# 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 -5
View File
@@ -12,8 +12,6 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many. Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
## Tests ## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers. - Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
@@ -48,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`. `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.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.
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. 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.
@@ -140,13 +138,13 @@ packages/ai/src/
ids.ts branded IDs, literal types, ProviderMetadata ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy options.ts Generation/Provider/Http options, Limits, Model, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure errors.ts error reasons, LLMError, ToolFailure
index.ts barrel index.ts barrel
llm.ts request constructors and convenience helpers llm.ts request constructors and convenience helpers
route/ route/
index.ts @opencode-ai/ai/route advanced barrel index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate client.ts Route.make + LLMClient.prepare/stream/generate
executor.ts RequestExecutor service + transport error mapping executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path endpoint.ts Endpoint type + Endpoint.path
+1
View File
@@ -196,6 +196,7 @@ 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. - **`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. - **`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. - **`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. - **`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. - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. - **`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(...)`. calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only - [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests. id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read - [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`. `request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution - [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels. path; keep route ids only as diagnostics/provider API labels.
+32 -2
View File
@@ -50,6 +50,18 @@ 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 // 3. `generate` sends the request and collects the event stream into one
// response object. `response.text` is the collected text output. // response object. `response.text` is the collected text output.
const generateOnce = Effect.gen(function* () { const generateOnce = Effect.gen(function* () {
@@ -210,15 +222,33 @@ 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 // Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, stream, or // enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// tool-loop behavior without spending tokens on every example. // or tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps)) const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
// yield* inspectFakeProvider
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
// yield* streamText // yield* streamText
// yield* generateStructuredObject // yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
+1 -2
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit // Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the // prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless. // whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"]) const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint => const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,7 +133,6 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
+11 -16
View File
@@ -9,28 +9,25 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
Model,
SystemPart, SystemPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
type ContentPart, type ContentPart,
type ModelProviderOptions,
} from "./schema" } from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput<SelectedModel extends Model = Model> = Omit< export type RequestInput = Omit<
ConstructorParameters<typeof LLMRequest>[0], ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & { > & {
readonly model: SelectedModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart> readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart> readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input> readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input> readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>> readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
} }
@@ -38,7 +35,7 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream export const stream = LLMClient.stream
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => { export const request = (input: RequestInput) => {
const { const {
system: requestSystem, system: requestSystem,
prompt, prompt,
@@ -66,7 +63,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice"> type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
export class GenerateObjectResponse<T> { export class GenerateObjectResponse<T> {
constructor( constructor(
@@ -83,13 +80,11 @@ export class GenerateObjectResponse<T> {
} }
} }
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model> export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase {
extends GenerateObjectBase<SelectedModel> {
readonly schema: S readonly schema: S
} }
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model> export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
extends GenerateObjectBase<SelectedModel> {
/** Raw JSON Schema object describing the expected output shape. */ /** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
} }
@@ -142,11 +137,11 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates. * the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/ */
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>( export function generateObject<S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedModel>, options: GenerateObjectOptions<S>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError> ): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
export function generateObject<const SelectedModel extends Model>( export function generateObject(
options: GenerateObjectDynamicOptions<SelectedModel>, options: GenerateObjectDynamicOptions,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError> ): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) { export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) { if ("schema" in options) {
+4 -58
View File
@@ -29,30 +29,9 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
export interface OptionsInput { export interface OptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: { readonly thinkingConfig?: {
readonly thinkingBudget?: number readonly thinkingBudget?: number
readonly includeThoughts?: boolean readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
} }
} }
@@ -132,12 +111,6 @@ const GeminiToolConfig = Schema.Struct({
const GeminiThinkingConfig = Schema.Struct({ const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number), thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean), includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: Schema.String,
threshold: Schema.String,
}) })
const GeminiGenerationConfig = Schema.Struct({ const GeminiGenerationConfig = Schema.Struct({
@@ -150,10 +123,7 @@ const GeminiGenerationConfig = Schema.Struct({
}) })
const GeminiBodyFields = { const GeminiBodyFields = {
cachedContent: Schema.optional(Schema.String),
contents: Schema.Array(GeminiContent), contents: Schema.Array(GeminiContent),
safetySettings: optionalArray(GeminiSafetySetting),
serviceTier: Schema.optional(Schema.String),
systemInstruction: Schema.optional(GeminiSystemInstruction), systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: optionalArray(GeminiTool), tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig), toolConfig: Schema.optional(GeminiToolConfig),
@@ -346,38 +316,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}) })
const resolveOptions = (request: LLMRequest) => { const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions?.gemini const value = request.providerOptions?.gemini?.thinkingConfig
const value = input?.thinkingConfig if (!ProviderShared.isRecord(value)) return {}
const thinkingConfig = { const thinkingConfig = {
thinkingBudget: thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
} }
return { return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined, thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
} }
} }
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const hasTools = request.tools.length > 0 const hasTools = request.tools.length > 0
const generation = request.generation const generation = request.generation
@@ -393,10 +342,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
} }
return { return {
cachedContent: options.cachedContent,
contents: yield* lowerMessages(request), contents: yield* lowerMessages(request),
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction: systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools tools: hasTools
+26 -125
View File
@@ -11,7 +11,6 @@ import {
Usage, Usage,
type FinishReason, type FinishReason,
type FinishReasonDetails, type FinishReasonDetails,
type CacheHint,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@@ -39,11 +38,6 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds // The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and // this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport. // JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({ const OpenAIChatFunction = Schema.Struct({
name: Schema.String, name: Schema.String,
description: Schema.String, description: Schema.String,
@@ -53,7 +47,6 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({ const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
function: OpenAIChatFunction, function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
}) })
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool> type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -68,11 +61,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall> type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([ const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("image_url"), type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }), image_url: Schema.Struct({ url: Schema.String }),
@@ -80,10 +69,7 @@ const OpenAIChatUserContent = Schema.Union([
]) ])
const OpenAIChatMessage = Schema.Union([ const OpenAIChatMessage = Schema.Union([
Schema.Struct({ Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({ Schema.Struct({
role: Schema.Literal("user"), role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -97,16 +83,10 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String), reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String), reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown), reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
), ),
Schema.Struct({ Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
]).pipe(Schema.toTaggedUnion("role")) ]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage> type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -127,7 +107,6 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number), max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number),
@@ -230,20 +209,13 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the // Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native // OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`. // fields into `LLMRequest`.
interface LoweringOptions { const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function", type: "function",
function: { function: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema), parameters: ToolSchemaProjection.openAI(inputSchema),
}, },
cache_control: options.cacheControl?.(tool.cache),
}) })
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -285,14 +257,11 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
} }
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) { for (const part of message.content) {
if (part.type === "text") { if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) }) content.push({ type: "text", text: part.text })
continue continue
} }
if (part.type === "media") { if (part.type === "media") {
@@ -301,18 +270,14 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
} }
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"]) return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
} }
if (content.every((part) => part.type === "text" && part.cache_control === undefined)) if (content.every((part) => part.type === "text"))
return { return { role: "user" as const, content: content.map((part) => part.text).join("") }
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
return { role: "user" as const, content } return { role: "user" as const, content }
}) })
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage, message: OpenAIChatRequestMessage,
configuredField?: string, configuredField?: string,
options: LoweringOptions = {},
) { ) {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoning: ReasoningPart[] = [] const reasoning: ReasoningPart[] = []
@@ -350,44 +315,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning if (reasoning.length === 0) return nativeReasoning
return text return text
})() })()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = { const result = {
role: "assistant" as const, role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content), content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls, tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details, reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
} }
if (field === undefined || reasoningText === undefined) return result if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText } return { ...result, [field]: reasoningText }
}) })
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* ( const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = [] const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"])) if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]) return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") { if (part.result.type !== "content") {
messages.push({ messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
continue continue
} }
const content: ReadonlyArray<Tool.Content> = part.result.value const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text) const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
const files = content.filter((item) => item.type === "file") const files = content.filter((item) => item.type === "file")
images.push( images.push(
...(yield* Effect.forEach(files, (item) => ...(yield* Effect.forEach(files, (item) =>
@@ -401,29 +351,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage, message: OpenAIChatRequestMessage,
reasoningField?: string, reasoningField?: string,
options: LoweringOptions = {},
) { ) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)] if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)] if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message, options)).messages return (yield* lowerToolMessages(message)).messages
}) })
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) { const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIChatMessage[] = const system: OpenAIChatMessage[] =
request.system.length === 0 request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system] const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = [] const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => { const flushImages = () => {
@@ -434,53 +370,28 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) { if (pendingImages.length > 0) {
messages.push({ messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
continue continue
} }
const previous = messages.at(-1) const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string") if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = options.cacheControl?.(part.cache) messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content)) else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = { messages[messages.length - 1] = {
role: "user", role: "user",
content: [ content: [...previous.content, { type: "text", text: part.text }],
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
} }
else else messages.push({ role: "user", content: part.text })
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
continue continue
} }
if (message.role === "tool") { if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options) const lowered = yield* lowerToolMessages(message)
messages.push(...lowered.messages) messages.push(...lowered.messages)
pendingImages.push(...lowered.images) pendingImages.push(...lowered.images)
continue continue
} }
flushImages() flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options))) messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
} }
flushImages() flushImages()
return messages return messages
@@ -494,10 +405,7 @@ const lowerOptions = (request: LLMRequest) => {
} }
} }
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
request: LLMRequest,
options: LoweringOptions = {},
) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing, // `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`. // validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField const reasoningField = request.model.compatibility?.reasoningField
@@ -507,26 +415,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
) )
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return { return {
model: request.model.id, model: request.model.id,
messages: yield* lowerMessages(request, options), messages: yield* lowerMessages(request),
tools: tools:
request.tools.length === 0 request.tools.length === 0
? undefined ? undefined
: request.tools.map((tool) => : request.tools.map((tool) =>
lowerTool( lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
), ),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const, stream: true as const,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens" max_tokens: generation?.maxTokens,
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty, frequency_penalty: generation?.frequencyPenalty,
+9 -8
View File
@@ -16,6 +16,7 @@ import {
const patterns = [ const patterns = [
/prompt is too long/i, /prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i, /input is too long for requested model/i,
/exceeds the context window/i, /exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -32,6 +33,7 @@ const patterns = [
/context window exceeds limit/i, /context window exceeds limit/i,
/exceeded model token limit/i, /exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i, /context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i, /context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i, /input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i, /prompt too long; exceeded (?:max )?context length/i,
@@ -42,15 +44,11 @@ const patterns = [
/token limit exceeded/i, /token limit exceeded/i,
] ]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) => export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) && !exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message)) (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError failure instanceof LLMError
@@ -102,8 +100,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text)) isContextOverflow(text))
) )
return new InvalidRequestReason({ ...common, classification: "context-overflow" }) return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common) if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common) return new QuotaExceededReason(common)
@@ -146,7 +142,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
}) })
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422) if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common) return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status }) return new UnknownProviderReason({ ...common, status: input.status })
} }
+3 -8
View File
@@ -1,21 +1,16 @@
import type { Model, ProviderOptions } from "./schema" import type { Model } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> { export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>> readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>> readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly limits?: {
readonly context: number readonly context: number
readonly input?: number
readonly output: number readonly output: number
} }
} }
export interface Definition< export interface Definition<ProviderSettings extends Settings = Settings> {
ProviderSettings extends Settings = Settings, readonly model: (modelID: string, settings: ProviderSettings) => Model
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
} }
export * as ProviderPackage from "./provider-package" export * as ProviderPackage from "./provider-package"
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
@@ -57,10 +57,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined) if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({ return configure({
+1 -4
View File
@@ -52,10 +52,7 @@ export const configure = (input: Config = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined) if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken") throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({ return configure({
+6 -14
View File
@@ -99,14 +99,10 @@ export const configure = (input: Config) => {
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
configuredResponsesRoute configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
configuredChatRoute configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return { return {
id, id,
@@ -137,12 +133,8 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL") throw new Error("Azure requires resourceName or baseURL")
} }
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = ( export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
modelID, configure(config(settings)).responses(modelID)
settings, export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
) => configure(config(settings)).responses(modelID) configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const model = responsesModel export const model = responsesModel
+4 -10
View File
@@ -4,7 +4,6 @@ import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai") export const workersAIID = ProviderID.make("cloudflare-workers-ai")
@@ -21,11 +20,10 @@ type GatewayURL = AtLeastOne<{
} }
export type AIGatewayOptions = GatewayURL & export type AIGatewayOptions = GatewayURL &
Omit<RouteDefaultsInput, "providerOptions"> & RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret readonly gatewayApiKey?: CloudflareSecret
readonly providerOptions?: OpenAIProviderOptionsInput
} }
type WorkersAIURL = AtLeastOne<{ type WorkersAIURL = AtLeastOne<{
@@ -33,11 +31,7 @@ type WorkersAIURL = AtLeastOne<{
readonly baseURL: string readonly baseURL: string
}> }>
export type WorkersAIOptions = WorkersAIURL & export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const aiGatewayBaseURL = (input: GatewayURL) => { export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL if (input.baseURL) return input.baseURL
@@ -104,7 +98,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
}) })
return { return {
id: aiGatewayID, id: aiGatewayID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureAIGateway, configure: configureAIGateway,
} }
} }
@@ -117,7 +111,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
}) })
return { return {
id: workersAIID, id: workersAIID,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureWorkersAI, configure: configureWorkersAI,
} }
} }
+2 -4
View File
@@ -50,11 +50,9 @@ export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options) const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options) const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
responsesRoute responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID }) chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return { return {
id, id,
model: (modelID: string | ModelID) => model: (modelID: string | ModelID) =>
@@ -1,9 +1,8 @@
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -12,7 +11,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -21,7 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: ProviderOptions
} }
const route = OpenAICompatibleChat.route.with({ const route = OpenAICompatibleChat.route.with({
@@ -58,7 +56,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
@@ -68,7 +66,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
@@ -101,10 +101,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
@@ -1,9 +1,8 @@
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared" import { GoogleVertexShared } from "./google-vertex-shared"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -12,7 +11,6 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -21,7 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string readonly baseURL?: string
readonly location?: string readonly location?: string
readonly project?: string readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput readonly providerOptions?: ProviderOptions
} }
const route = OpenAICompatibleResponses.route.with({ const route = OpenAICompatibleResponses.route.with({
@@ -60,7 +58,7 @@ export const configure = (input: Config = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
@@ -70,10 +68,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys") if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({ return configure({
accessToken: settings.accessToken, accessToken: settings.accessToken,
+2 -6
View File
@@ -77,8 +77,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
return { return {
id, id,
model: (modelID: string | ModelID) => model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
configure, configure,
} }
} }
@@ -87,10 +86,7 @@ export const provider = {
id, id,
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined) if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({ return configure({
+2 -2
View File
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
}) })
return { return {
id, id,
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
image, image,
configure, configure,
} }
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
@@ -46,10 +46,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = ( export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
modelID,
settings,
) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
@@ -4,15 +4,13 @@ import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
import type { OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("openai-compatible") export const id = ProviderID.make("openai-compatible")
type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & type GenericModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly provider?: string readonly provider?: string
readonly baseURL: string readonly baseURL: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface Settings extends ProviderPackage.Settings { export interface Settings extends ProviderPackage.Settings {
@@ -21,10 +19,9 @@ export interface Settings extends ProviderPackage.Settings {
readonly provider?: string readonly provider?: string
} }
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type FamilyModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export const routes = [OpenAICompatibleChat.route] export const routes = [OpenAICompatibleChat.route]
@@ -40,8 +37,7 @@ export const configure = (input: GenericModelOptions) => {
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
configure, configure,
} }
} }
@@ -67,7 +63,7 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
+6 -13
View File
@@ -86,15 +86,10 @@ export const configure = (input: Config = {}) => {
const chatRoute = configuredRoute(OpenAIChat.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input) const modelDefaults = defaults(input)
const responses = (id: string | ModelID) => const responses = (id: string | ModelID) =>
responsesRoute responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) => const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
OpenAIImages.model({ OpenAIImages.model({
id: modelID, id: modelID,
@@ -137,17 +132,15 @@ const config = (settings: Settings): Config => {
} }
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => { export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
const configured = configure(config(settings)) const configured = configure(config(settings))
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
} }
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = ( export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
modelID, configure(config(settings)).chat(modelID)
settings,
) => configure(config(settings)).chat(modelID)
export const responses = provider.responses export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat export const chat = provider.chat
+12 -104
View File
@@ -4,72 +4,20 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared" import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider) export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter" const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions { export interface OpenRouterOptions {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly debug?: Readonly<{ echo_upstream_body?: boolean }> readonly usage?: boolean | Record<string, unknown>
readonly models?: ReadonlyArray<string> readonly reasoning?: Record<string, unknown>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
} }
export type OpenRouterProviderOptionsInput = ProviderOptions & { export type OpenRouterProviderOptionsInput = ProviderOptions & {
@@ -82,12 +30,6 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput 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), [ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any), Schema.Record(Schema.String, Schema.Any),
]) ])
@@ -98,7 +40,7 @@ export const protocol = Protocol.make({
body: { body: {
schema: OpenRouterBody, schema: OpenRouterBody,
from: (request) => from: (request) =>
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe( OpenAIChat.protocol.body.from(request).pipe(
Effect.map((body) => { Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant") const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0 let assistantIndex = 0
@@ -129,39 +71,16 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream, stream: OpenAIChat.protocol.stream,
}) })
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => { const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {} const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return { return {
...options, ...(openrouter.usage === true
...(usage === undefined || usage === true
? { usage: { include: true } } ? { usage: { include: true } }
: usage === false : isRecord(openrouter.usage)
? { usage: { include: false } } ? { usage: openrouter.usage }
: isRecord(usage) : {}),
? { usage } ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
: {}), ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
} }
} }
@@ -188,21 +107,10 @@ export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }), model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure, configure,
} }
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = ( export const model = provider.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)
+11 -51
View File
@@ -1,62 +1,29 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Route, type RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { HttpOptions, ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images" 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 const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & { export type ModelOptions = RouteDefaultsInput &
readonly xai?: OpenAIOptionsInput
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string 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 type { XAIImageOptions } from "../protocols/xai-images"
const responsesRoute = Route.make({ export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
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 auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => { const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({ return OpenAIResponses.route.with({
...rest, ...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input), auth: auth(input),
}) })
@@ -64,8 +31,9 @@ const configuredResponsesRoute = (input: ModelOptions) => {
const configuredChatRoute = (input: ModelOptions) => { const configuredChatRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({ return OpenAICompatibleChat.route.with({
...rest, ...rest,
provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input), auth: auth(input),
}) })
@@ -74,8 +42,8 @@ const configuredChatRoute = (input: ModelOptions) => {
export const configure = (input: ModelOptions = {}) => { export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input) const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input) const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID }) const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID }) const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
const image = (modelID: string | ModelID) => const image = (modelID: string | ModelID) =>
XAIImages.model({ XAIImages.model({
id: modelID, id: modelID,
@@ -95,15 +63,7 @@ export const configure = (input: ModelOptions = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) => export const model = provider.model
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 responses = provider.responses
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
+46 -40
View File
@@ -5,12 +5,12 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import { Framing } from "./framing" import { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport" import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy" import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema" import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
@@ -20,6 +20,7 @@ import {
ModelLimits, ModelLimits,
LLMError as LLMErrorClass, LLMError as LLMErrorClass,
LLMEvent, LLMEvent,
PreparedRequest,
ProviderID, ProviderID,
mergeGenerationOptions, mergeGenerationOptions,
mergeHttpOptions, mergeHttpOptions,
@@ -45,12 +46,8 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults readonly defaults: RouteDefaults
readonly body: RouteBody<Body> readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared> readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options> readonly model: (input: RouteMappedModelInput) => Model
readonly prepareTransport: ( readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
body: Body,
request: LLMRequest,
options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: ( readonly streamPrepared: (
prepared: Prepared, prepared: Prepared,
request: LLMRequest, request: LLMRequest,
@@ -96,15 +93,12 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>( const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
route: AnyRoute,
mapped: RouteMappedModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint)) if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`) throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make<Options>({ return Model.make({
...mapped, ...mapped,
provider, provider,
route, route,
@@ -148,20 +142,27 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
} }
export interface Interface { 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 stream: StreamMethod
readonly generate: GenerateMethod readonly generate: GenerateMethod
} }
export interface StreamOptions {
readonly http?: HttpMiddleware
}
export interface StreamMethod { export interface StreamMethod {
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> (request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
} }
export interface GenerateMethod { export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> (request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -295,9 +296,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults), defaults: mergeRouteDefaults(route.defaults, defaults),
}) })
}, },
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => model: (input) => makeRouteModel(route, input),
makeRouteModel<Options>(route, input), prepareTransport: (body, request) =>
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({ routeInput.transport.prepare({
body, body,
request, request,
@@ -305,7 +305,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none, auth: routeInput.auth ?? Auth.none,
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
middleware: options?.http,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
@@ -371,14 +370,17 @@ export function make<Body, Prepared, Frame, Event, State>(
}) })
} }
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) { // `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 resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route const route = resolved.model.route
const body = yield* route.body const body = yield* route.body
.from(resolved) .from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved, options) const prepared = yield* route.prepareTransport(body, resolved)
return { return {
request: resolved, request: resolved,
@@ -388,30 +390,30 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options
} }
}) })
/** @internal Test-only projection of the execution compiler; not exported from package barrels. */ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
const compiled = yield* compile(request) const compiled = yield* compile(request)
return {
return new PreparedRequest({
id: compiled.request.id ?? "request", id: compiled.request.id ?? "request",
route: compiled.route.id, route: compiled.route.id,
protocol: compiled.route.protocol, protocol: compiled.route.protocol,
model: compiled.request.model, model: compiled.request.model,
body: compiled.body, body: compiled.body,
metadata: { transport: compiled.route.transport.id }, metadata: { transport: compiled.route.transport.id },
} })
}) })
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) => const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const compiled = yield* compile(request, options) const compiled = yield* compile(request)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}), }),
) )
const generateWith = (stream: Interface["stream"]) => const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) { Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state) const response = LLMResponse.complete(state)
if (response) return response if (response) return response
return yield* ProviderShared.eventError( return yield* ProviderShared.eventError(
@@ -420,24 +422,27 @@ const generateWith = (stream: Interface["stream"]) =>
) )
}) })
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> { 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( return Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
return (yield* Service).stream(request, options) return (yield* Service).stream(request)
}), }),
) as Stream.Stream<LLMEvent, LLMError> ) as Stream.Stream<LLMEvent, LLMError>
} }
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> { export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
return Effect.gen(function* () { return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options) return yield* (yield* Service).generate(request)
}) as Effect.Effect<LLMResponse, LLMError> }) as Effect.Effect<LLMResponse, LLMError>
} }
export const streamRequest = (request: LLMRequest, options?: StreamOptions) => export const streamRequest = (request: LLMRequest) =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
return (yield* Service).stream(request, options) return (yield* Service).stream(request)
}), }),
) )
@@ -448,7 +453,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
http: yield* RequestExecutor.Service, http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)), webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}) })
return Service.of({ stream, generate: generateWith(stream) }) return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}), }),
) )
@@ -457,6 +462,7 @@ export const Route = { make } as const
export const LLMClient = { export const LLMClient = {
Service, Service,
layer, layer,
prepare,
stream, stream,
generate, generate,
} as const } as const
+5 -38
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Stream } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -20,13 +20,9 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError> ) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
} }
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
const BODY_LIMIT = 16_384 const BODY_LIMIT = 16_384
@@ -286,41 +282,12 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) return yield* http
return yield* http .execute(request)
.execute(request) .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
let sent = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const response = yield* HttpClientRequest.toWeb(request).pipe(
Effect.flatMap((web) =>
middleware(web, (input) =>
Effect.gen(function* () {
sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
const response = yield* http
.execute(sent)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
const body = yield* Stream.toReadableStreamEffect(response.stream)
const web = new Response(body, { status: response.status, headers: response.headers })
origins.set(web, sent)
return web
}),
),
),
Effect.mapError(toHttpError(redactedNames)),
)
const origin = origins.get(response) ?? sent
return yield* statusError(origin, redactedNames)(HttpClientResponse.fromWeb(origin, response))
}) })
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
+1 -2
View File
@@ -8,7 +8,6 @@ export type {
AnyRoute, AnyRoute,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
StreamOptions,
} from "./client" } from "./client"
export * from "./executor" export * from "./executor"
export { Auth } from "./auth" export { Auth } from "./auth"
@@ -23,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint" export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing" export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol" export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport" export type { Transport as TransportDef, TransportRuntime } from "./transport"
+57 -15
View File
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth" import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint" import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing" import { Framing } from "../framing"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index" import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared" import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema" import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,7 +19,6 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> { export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame> readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
} }
const applyQuery = (url: string, query: Record<string, string> | undefined) => { const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -29,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString() return next.toString()
} }
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () { Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) { if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {} const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
@@ -73,23 +120,18 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
id: "http-json", id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }), with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) => prepare: (prepareInput) =>
Effect.gen(function* () { jsonRequestParts({
const parts = yield* jsonRequestParts({ ...prepareInput }) ...prepareInput,
const request = ProviderShared.jsonPost({ }).pipe(
url: parts.url, Effect.map((parts) => ({
body: parts.bodyText, request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
headers: parts.headers,
})
return {
request,
framing: input.framing, framing: input.framing,
middleware: prepareInput.middleware, })),
} ),
}),
frames: (prepared, request, runtime) => frames: (prepared, request, runtime) =>
Stream.unwrap( Stream.unwrap(
runtime.http runtime.http
.execute(prepared.request, prepared.middleware) .execute(prepared.request)
.pipe( .pipe(
Effect.map((response) => Effect.map((response) =>
prepared.framing.frame( prepared.framing.frame(
+1 -3
View File
@@ -1,7 +1,7 @@
import type { Effect, Stream } from "effect" import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint" import { Endpoint } from "../endpoint"
import { Auth } from "../auth" import { Auth } from "../auth"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor" import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket" import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema" import type { LLMError, LLMRequest } from "../../schema"
@@ -27,9 +27,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware
} }
export * as HttpTransport from "./http" export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket" export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({ export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
+25 -1
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors" import { ProviderFailureClassification } from "./errors"
@@ -313,6 +314,29 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}) })
export type LLMEvent = Schema.Schema.Type<typeof 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>) => const responseText = (events: ReadonlyArray<LLMEvent>) =>
events events
.filter(LLMEvent.is.textDelta) .filter(LLMEvent.is.textDelta)
-2
View File
@@ -124,7 +124,6 @@ export const ToolCallPart = Object.assign(
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }), }).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -169,7 +168,6 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"), type: Schema.Literal("reasoning"),
text: Schema.String, text: Schema.String,
encrypted: Schema.optional(Schema.String), encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" }) }).annotate({ identifier: "LLM.Content.Reasoning" })
+6 -14
View File
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({ export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number), context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number), output: Schema.optional(Schema.Number),
}) {} }) {}
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility> export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({ export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility), toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {} }) {}
export namespace ModelCompatibility { export namespace ModelCompatibility {
@@ -183,8 +178,7 @@ export namespace ModelCompatibility {
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
} }
export class Model<Options extends ProviderOptions = ProviderOptions> { export class Model {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
@@ -199,8 +193,8 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility this.compatibility = input.compatibility
} }
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) { static make(input: Model.Input) {
return new Model<Options>({ return new Model({
id: ModelID.make(input.id), id: ModelID.make(input.id),
provider: ProviderID.make(input.provider), provider: ProviderID.make(input.provider),
route: input.route, route: input.route,
@@ -209,7 +203,7 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
}) })
} }
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput { static input(model: Model): Model.ConstructorInput {
return { return {
id: model.id, id: model.id,
provider: model.provider, provider: model.provider,
@@ -219,9 +213,9 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
} }
} }
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) { static update(model: Model, patch: Partial<Model.Input>) {
if (Object.keys(patch).length === 0) return model if (Object.keys(patch).length === 0) return model
return Model.make<Options>({ return Model.make({
...Model.input(model), ...Model.input(model),
...patch, ...patch,
}) })
@@ -247,8 +241,6 @@ export namespace Model {
export type ModelInput = Model.Input 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 const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
+1
View File
@@ -99,6 +99,7 @@ export const layer = (options: LayerOptions = {}) =>
) )
}) as LLMClientShape["stream"] }) as LLMClientShape["stream"]
const client = LLMClient.Service.of({ const client = LLMClient.Service.of({
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
stream, stream,
generate: (request) => generate: (request) =>
stream(request).pipe( stream(request).pipe(
+3 -3
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src" import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { compileRequest } from "../src/route/client"
import { Model } from "../src/schema" import { Model } from "../src/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
@@ -140,7 +139,8 @@ describe("llm route", () => {
it.effect("selects routes by model route value", () => it.effect("selects routes by model route value", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const llm = yield* LLMClient.Service
const prepared = yield* llm.prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }), LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
) )
@@ -173,7 +173,7 @@ describe("llm route", () => {
framing: fakeFraming, framing: fakeFraming,
}) })
const prepared = yield* compileRequest( const prepared = yield* (yield* LLMClient.Service).prepare(
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }), LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
) )
+13 -14
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src" import { CacheHint, LLM, Message } from "../src"
import { Auth } from "../src/route" import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { AmazonBedrock } from "../src/providers" import { AmazonBedrock } from "../src/providers"
import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as Gemini from "../src/protocols/gemini" import * as Gemini from "../src/protocols/gemini"
@@ -32,7 +31,7 @@ const geminiModel = Gemini.route
describe("applyCachePolicy", () => { describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () => it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "You are concise.", system: "You are concise.",
@@ -51,7 +50,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () => it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: [ system: [
@@ -88,7 +87,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: openaiModel, model: openaiModel,
system: "Sys", system: "Sys",
@@ -107,7 +106,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: geminiModel, model: geminiModel,
system: "Sys", system: "Sys",
@@ -124,7 +123,7 @@ describe("applyCachePolicy", () => {
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: bedrockModel, model: bedrockModel,
system: [ system: [
@@ -158,7 +157,7 @@ describe("applyCachePolicy", () => {
it.effect("'none' disables auto placement even when manual hints exist", () => it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -177,7 +176,7 @@ describe("applyCachePolicy", () => {
it.effect("granular object form: tools-only marks just tools", () => it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -196,7 +195,7 @@ describe("applyCachePolicy", () => {
it.effect("auto policy preserves manual CacheHints on other parts", () => it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: [ system: [
@@ -242,7 +241,7 @@ describe("applyCachePolicy", () => {
expect("cache" in tail ? tail.cache : undefined).toBeUndefined() expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
expect(applyCachePolicy(applied)).toBe(applied) expect(applyCachePolicy(applied)).toBe(applied)
const prepared = yield* compileRequest(request) const prepared = yield* LLMClient.prepare(request)
const body = prepared.body as { const body = prepared.body as {
tools: Array<{ cache_control?: unknown }> tools: Array<{ cache_control?: unknown }>
@@ -262,7 +261,7 @@ describe("applyCachePolicy", () => {
it.effect("ttlSeconds in the policy flows through to wire markers", () => it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
system: "Sys", system: "Sys",
@@ -279,7 +278,7 @@ describe("applyCachePolicy", () => {
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
@@ -297,7 +296,7 @@ describe("applyCachePolicy", () => {
it.effect("'latest-assistant' marks the last assistant message", () => it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: anthropicModel, model: anthropicModel,
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
+3 -6
View File
@@ -85,17 +85,14 @@ describe("RequestExecutor", () => {
), ),
) )
it.effect("classifies generic HTTP 413 payload errors", () => it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectLLMError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
_tag: "InvalidRequest", expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
classification: "payload-too-large",
http: { response: { status: 413 } },
})
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
) )
+2
View File
@@ -53,7 +53,9 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction() expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction() expect(OpenRouter.model).toBeFunction()
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
expect(XAI.model).toBeFunction() expect(XAI.model).toBeFunction()
expect(XAI.provider.model).toBe(XAI.model)
expect(XAI.provider.responses).toBe(XAI.responses) expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat) expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
@@ -1,47 +0,0 @@
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
@@ -1,10 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect" import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src" import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route" import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks" import { deltaChunk } from "./lib/openai-chunks"
@@ -45,7 +44,7 @@ describe("request option precedence", () => {
}) })
}) })
it.effect("compiles bodies with route defaults, model defaults, and call options in order", () => it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const route = OpenAIChat.route.with({ const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" }, endpoint: { baseURL: "https://api.openai.test/v1/" },
@@ -60,7 +59,7 @@ describe("request option precedence", () => {
providerOptions: { openai: { reasoningEffort: "medium" } }, providerOptions: { openai: { reasoningEffort: "medium" } },
}, },
}) })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
prompt: "Say hello.", prompt: "Say hello.",
@@ -137,149 +136,26 @@ describe("request option precedence", () => {
), ),
) )
it.effect("transforms the final HTTP request after serialization and authentication", () => it.effect("rejects raw body overlays for protocol-owned roots", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
const headers = new Headers(request.headers)
headers.set("x-plugin", "transformed")
headers.set("content-type", "application/custom+json")
return yield* handler(
new Request("https://proxy.test/v1/chat/completions", {
method: "PUT",
headers,
body: JSON.stringify({ transformed: true }),
}),
)
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.method).toBe("PUT")
expect(web.headers.get("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( 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(
LLM.request({ LLM.request({
model: OpenAIChat.route model,
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.", prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}), }),
{ ).pipe(Effect.flip)
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
const body = yield* Effect.promise(() => response.text())
return new Response(body.replace("network", "hooked"), {
status: response.status,
headers: response.headers,
})
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked") expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}), }),
) )
it.effect("can inspect an error response and retry the native request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const retry = request.clone()
const response = yield* handler(request)
expect(response.status).toBe(401)
const headers = new Headers(retry.headers)
headers.set("authorization", "Bearer refreshed")
return yield* handler(new Request(retry, { headers }))
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
if (input.request.headers.authorization !== "Bearer refreshed")
return input.respond("unauthorized", { status: 401 })
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("retried")
expect(yield* Ref.get(attempts)).toBe(2)
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("uses model output limits after route limits and before call maxTokens", () => it.effect("uses model output limits after route limits and before call maxTokens", () =>
Effect.gen(function* () { Effect.gen(function* () {
const route = AnthropicMessages.route.with({ const route = AnthropicMessages.route.with({
@@ -288,8 +164,10 @@ describe("request option precedence", () => {
limits: { output: 128 }, limits: { output: 128 },
}) })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
const withMaxTokens = yield* compileRequest( LLM.request({ model, prompt: "Say hello.", cache: "none" }),
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
) )
+4 -22
View File
@@ -6,6 +6,7 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => { test("classifies provider token limit messages as context overflow", () => {
const messages = [ const messages = [
"tokens in request more than max tokens allowed", "tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.", "Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).", "Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -18,24 +19,6 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true) expect(messages.every(isContextOverflow)).toBe(true)
}) })
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => { test("does not classify rate limits as context overflow", () => {
const messages = [ const messages = [
"Throttling error: Too many tokens, please wait before trying again.", "Throttling error: Too many tokens, please wait before trying again.",
@@ -76,10 +59,9 @@ describe("provider error classification", () => {
}) })
test("classifies transient client statuses as provider internal", () => { test("classifies transient client statuses as provider internal", () => {
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([ expect(
"ProviderInternal", [408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
"ProviderInternal", ).toEqual(["ProviderInternal", "ProviderInternal"])
])
}) })
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {
@@ -1,13 +0,0 @@
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 } },
})
@@ -1,13 +0,0 @@
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" } } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,13 +0,0 @@
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 } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,13 +0,0 @@
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 } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,17 +0,0 @@
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" } } },
})
@@ -1,47 +0,0 @@
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",
providerOptions: {
gemini: {
// @ts-expect-error Gemini safety settings require a threshold for every category.
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "future-tier",
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
})
LLM.request({
model,
prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,13 +0,0 @@
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" } },
})
@@ -1,13 +0,0 @@
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 } },
})
@@ -1,35 +0,0 @@
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",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error OpenRouter usage must be boolean or an option record.
providerOptions: { openrouter: { usage: "yes" } },
})
@@ -1,13 +0,0 @@
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,8 +21,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/chat"), import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"), import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"), 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() for (const module of modules) expect(module.model).toBeFunction()
@@ -31,35 +29,6 @@ describe("provider package entrypoints", () => {
expect(modules[12].model).toBe(modules[13].model) 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", () => { test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey: "fixture", apiKey: "fixture",
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -45,7 +44,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro
describe("Anthropic Messages route", () => { describe("Anthropic Messages route", () => {
it.effect("prepares Anthropic Messages target", () => it.effect("prepares Anthropic Messages target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(request) const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
model: "claude-sonnet-4-5", model: "claude-sonnet-4-5",
@@ -60,7 +59,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers adaptive thinking settings with effort", () => it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
@@ -77,17 +76,17 @@ describe("Anthropic Messages route", () => {
it.effect("normalizes enabled and disabled thinking settings", () => it.effect("normalizes enabled and disabled thinking settings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const enabled = yield* compileRequest( const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } }, providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
}), }),
) )
const legacy = yield* compileRequest( const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } }, providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
}), }),
) )
const disabled = yield* compileRequest( const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } }, providerOptions: { anthropic: { thinking: { type: "disabled" } } },
}), }),
@@ -101,7 +100,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects enabled thinking without a budget", () => it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } }, providerOptions: { anthropic: { thinking: { type: "enabled" } } },
}), }),
@@ -113,7 +112,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model: opus48, model: opus48,
messages: [ messages: [
@@ -138,7 +137,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () => it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -165,7 +164,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects non-text chronological system update content before send", () => it.effect("rejects non-text chronological system update content before send", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: opus48, model: opus48,
messages: [ messages: [
@@ -182,7 +181,7 @@ describe("Anthropic Messages route", () => {
it.effect("falls back for unsupported native chronological system update placement", () => it.effect("falls back for unsupported native chronological system update placement", () =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
(yield* compileRequest( (yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model: opus48, model: opus48,
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")], messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
@@ -197,11 +196,12 @@ describe("Anthropic Messages route", () => {
}, },
]) ])
expect( expect(
(yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }))) (yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
.body.messages, 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>" }] }]) ).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
expect( expect(
(yield* compileRequest( (yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model: opus48, model: opus48,
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")], 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", () => it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: opus48, model: opus48,
messages: [ messages: [
@@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares tool call and tool result messages", () => it.effect("prepares tool call and tool result messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
id: "req_tool_result", id: "req_tool_result",
model, model,
@@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => {
it.effect("keeps tools and sends tool_choice none", () => it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
id: "req_tool_choice_none", id: "req_tool_choice_none",
model, model,
@@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => {
// not JSON-stringified into `tool_result.content`. // not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () => it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
id: "req_tool_result_image", id: "req_tool_result_image",
model, model,
@@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers single-image tool-result content as a structured image block", () => it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
id: "req_tool_result_image_only", id: "req_tool_result_image_only",
model, model,
@@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () => it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_result_unsupported_media", id: "req_tool_result_unsupported_media",
model, model,
@@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => {
it.effect("prepares the composed native continuation request", () => it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
continuationRequest({ continuationRequest({
id: "req_native_continuation_anthropic", id: "req_native_continuation_anthropic",
model, model,
@@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => {
it.effect("lowers preserved Anthropic reasoning signature metadata", () => it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips redacted thinking as redacted_thinking blocks", () => it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -628,7 +628,9 @@ describe("Anthropic Messages route", () => {
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } }, { type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([ expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] }, { role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
]) ])
@@ -771,7 +773,7 @@ describe("Anthropic Messages route", () => {
), ),
), ),
) )
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -1131,7 +1133,7 @@ describe("Anthropic Messages route", () => {
it.effect("round-trips provider-executed assistant content into server tool blocks", () => it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_round_trip", id: "req_round_trip",
model, model,
@@ -1182,7 +1184,7 @@ describe("Anthropic Messages route", () => {
it.effect("rejects round-trip for unknown server tool names", () => it.effect("rejects round-trip for unknown server tool names", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_unknown_server_tool", id: "req_unknown_server_tool",
model, model,
@@ -1259,7 +1261,7 @@ describe("Anthropic Messages route", () => {
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
@@ -1275,7 +1277,7 @@ describe("Anthropic Messages route", () => {
it.effect("emits cache_control on tool definitions and tool-result blocks", () => it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
tools: [ tools: [
@@ -1316,7 +1318,7 @@ describe("Anthropic Messages route", () => {
it.effect("drops cache_control breakpoints past the 4-per-request cap", () => it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
Effect.gen(function* () { Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" }) const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
system: [ system: [
@@ -1342,7 +1344,7 @@ describe("Anthropic Messages route", () => {
it.effect("spends breakpoint budget on tools before system before messages", () => it.effect("spends breakpoint budget on tools before system before messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const hint = new CacheHint({ type: "ephemeral" }) const hint = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
tools: [ tools: [
@@ -13,7 +13,6 @@ import {
ToolDefinition, ToolDefinition,
} from "../../src" } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { AmazonBedrock } from "../../src/providers" import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse" import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -102,7 +101,7 @@ const baseRequest = LLM.request({
describe("Bedrock Converse route", () => { describe("Bedrock Converse route", () => {
it.effect("prepares Converse target with system, inference config, and messages", () => it.effect("prepares Converse target with system, inference config, and messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest) const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -115,7 +114,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes topK through additionalModelRequestFields as top_k", () => it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLMRequest.update(baseRequest, { LLMRequest.update(baseRequest, {
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }), generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
}), }),
@@ -130,14 +129,14 @@ describe("Bedrock Converse route", () => {
it.effect("omits additionalModelRequestFields when topK is unset", () => it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest) const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
expect(prepared.body.additionalModelRequestFields).toBeUndefined() expect(prepared.body.additionalModelRequestFields).toBeUndefined()
}), }),
) )
it.effect("lowers chronological system updates to wrapped user text in order", () => it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -154,7 +153,7 @@ describe("Bedrock Converse route", () => {
it.effect("prepares tool config with toolSpec and toolChoice", () => it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, { LLMRequest.update(baseRequest, {
tools: [ tools: [
ToolDefinition.make({ ToolDefinition.make({
@@ -188,7 +187,7 @@ describe("Bedrock Converse route", () => {
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () => it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLMRequest.update(baseRequest, { LLMRequest.update(baseRequest, {
tools: [ tools: [
ToolDefinition.make({ ToolDefinition.make({
@@ -218,7 +217,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers assistant tool-call + tool-result message history", () => it.effect("lowers assistant tool-call + tool-result message history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_history", id: "req_history",
model, model,
@@ -257,7 +256,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image content in tool-result messages", () => it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_image", id: "req_tool_image",
model, model,
@@ -492,7 +491,7 @@ describe("Bedrock Converse route", () => {
providerMetadata: { bedrock: { signature: "sig_1" } }, providerMetadata: { bedrock: { signature: "sig_1" } },
}) })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -547,7 +546,9 @@ describe("Bedrock Converse route", () => {
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ model, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([ expect(prepared.body.messages).toEqual([
{ {
role: "assistant", role: "assistant",
@@ -638,7 +639,7 @@ describe("Bedrock Converse route", () => {
text: "", text: "",
providerMetadata: { bedrock: { redactedData } }, providerMetadata: { bedrock: { redactedData } },
}) })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -753,7 +754,7 @@ describe("Bedrock Converse route", () => {
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}, },
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0") }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed })) const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
expect(prepared.route).toBe("bedrock-converse") expect(prepared.route).toBe("bedrock-converse")
expect(prepared.model).toBe(signed) expect(prepared.model).toBe(signed)
@@ -763,7 +764,7 @@ describe("Bedrock Converse route", () => {
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" }) const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_cache", id: "req_cache",
model, model,
@@ -795,7 +796,7 @@ describe("Bedrock Converse route", () => {
it.effect("does not emit cachePoint when no cache hint is set", () => it.effect("does not emit cachePoint when no cache hint is set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest) const prepared = yield* LLMClient.prepare(baseRequest)
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
system: [{ text: "You are concise." }], system: [{ text: "You are concise." }],
messages: [{ role: "user", content: [{ text: "Say hello." }] }], messages: [{ role: "user", content: [{ text: "Say hello." }] }],
@@ -805,7 +806,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers image media into Bedrock image blocks", () => it.effect("lowers image media into Bedrock image blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_image", id: "req_image",
model, model,
@@ -842,7 +843,7 @@ describe("Bedrock Converse route", () => {
it.effect("base64-encodes Uint8Array image bytes", () => it.effect("base64-encodes Uint8Array image bytes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_image_bytes", id: "req_image_bytes",
model, model,
@@ -864,7 +865,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media into Bedrock document blocks with format and name", () => it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_doc", id: "req_doc",
model, model,
@@ -896,7 +897,7 @@ describe("Bedrock Converse route", () => {
it.effect("requires names for document media", () => it.effect("requires names for document media", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })], messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
@@ -909,7 +910,7 @@ describe("Bedrock Converse route", () => {
it.effect("passes named document-only messages through for provider validation", () => it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
cache: "none", cache: "none",
@@ -935,7 +936,7 @@ describe("Bedrock Converse route", () => {
it.effect("lowers document media in tool results", () => it.effect("lowers document media in tool results", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({ LLM.request({
model, model,
cache: "none", cache: "none",
@@ -987,7 +988,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported image media types", () => it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_bad_image", id: "req_bad_image",
model, model,
@@ -1001,7 +1002,7 @@ describe("Bedrock Converse route", () => {
it.effect("rejects unsupported document media types", () => it.effect("rejects unsupported document media types", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_bad_doc", id: "req_bad_doc",
model, model,
@@ -1016,7 +1017,7 @@ describe("Bedrock Converse route", () => {
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
system: [{ type: "text", text: "system", cache }], system: [{ type: "text", text: "system", cache }],
@@ -1033,7 +1034,7 @@ describe("Bedrock Converse route", () => {
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" }) const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
@@ -1065,7 +1066,7 @@ describe("Bedrock Converse route", () => {
it.effect("drops cachePoint markers past the 4-per-request cap", () => it.effect("drops cachePoint markers past the 4-per-request cap", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" }) const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
system: [ system: [
+5 -5
View File
@@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent } from "../../src" import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare" import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { compileRequest } from "../../src/route/client" import { LLMClient } from "../../src/route"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http" import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse" 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") expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-ai-gateway") expect(prepared.route).toBe("cloudflare-ai-gateway")
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
@@ -129,7 +129,7 @@ describe("Cloudflare", () => {
openai: { reasoningField: "reasoning", reasoningDetails: merged }, openai: { reasoningField: "reasoning", reasoningDetails: merged },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged }, { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
]) ])
@@ -180,7 +180,7 @@ describe("Cloudflare", () => {
it.effect("allows a fully configured baseURL override", () => it.effect("allows a fully configured baseURL override", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: CloudflareAIGateway.configure({ model: CloudflareAIGateway.configure({
baseURL: "https://gateway.proxy.test/v1/custom/compat", 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") expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("cloudflare-workers-ai") expect(prepared.route).toBe("cloudflare-workers-ai")
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
+14 -43
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -27,7 +26,7 @@ const request = LLM.request({
describe("Gemini route", () => { describe("Gemini route", () => {
it.effect("prepares Gemini target", () => it.effect("prepares Gemini target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(request) const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
contents: [{ role: "user", parts: [{ text: "Say hello." }] }], contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
@@ -39,56 +38,28 @@ describe("Gemini route", () => {
it.effect("normalizes Gemini thinking options", () => it.effect("normalizes Gemini thinking options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
gemini: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
serviceTier: "priority",
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
},
}), }),
) )
const filtered = yield* compileRequest( const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
}), }),
) )
const defaulted = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
}),
)
const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } },
}),
)
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({ expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
thinkingBudget: 0, thinkingBudget: 0,
includeThoughts: false, includeThoughts: false,
thinkingLevel: "high",
}) })
expect(prepared.body.cachedContent).toBe("cachedContents/example")
expect(prepared.body.safetySettings).toEqual([
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
])
expect(prepared.body.serviceTier).toBe("priority")
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false }) expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
expect(emptySafetySettings.body.safetySettings).toEqual([])
}), }),
) )
it.effect("lowers chronological system updates to wrapped user text in order", () => it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({ LLM.request({
model, model,
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")], messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
@@ -104,7 +75,7 @@ describe("Gemini route", () => {
it.effect("prepares multimodal user input and tool history", () => it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_result", id: "req_tool_result",
model, model,
@@ -172,7 +143,7 @@ describe("Gemini route", () => {
it.effect("continues media tool results as inline model input without base64 text", () => it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -217,7 +188,7 @@ describe("Gemini route", () => {
it.effect("strips matching data URLs to raw base64 inlineData", () => it.effect("strips matching data URLs to raw base64 inlineData", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -258,7 +229,7 @@ describe("Gemini route", () => {
] as const) ] as const)
it.effect(`rejects ${name}`, () => it.effect(`rejects ${name}`, () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip) ).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/) expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -267,7 +238,7 @@ describe("Gemini route", () => {
it.effect("rejects oversized image input", () => it.effect("rejects oversized image input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -285,7 +256,7 @@ describe("Gemini route", () => {
it.effect("keeps tools and sends function calling mode NONE", () => it.effect("keeps tools and sends function calling mode NONE", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_choice_none", id: "req_tool_choice_none",
model, model,
@@ -305,7 +276,7 @@ describe("Gemini route", () => {
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_schema_patch", id: "req_schema_patch",
model, model,
@@ -486,7 +457,7 @@ describe("Gemini route", () => {
response.events.findIndex((event) => event.type === "tool-call"), response.events.findIndex((event) => event.type === "tool-call"),
) )
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -720,7 +691,7 @@ describe("Gemini route", () => {
it.effect("rejects unsupported assistant media content", () => it.effect("rejects unsupported assistant media content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_media", id: "req_media",
model, model,
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src" import { LLM } from "../../src"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http" import { dynamicResponse } from "../lib/http"
import { deltaChunk, finishChunk } from "../lib/openai-chunks" import { deltaChunk, finishChunk } from "../lib/openai-chunks"
@@ -181,6 +180,23 @@ describe("Google Vertex providers", () => {
}), }),
) )
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () => it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
@@ -5,7 +5,6 @@ import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { recordedTests } from "../recorded-test" import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios" import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
@@ -85,7 +84,9 @@ for (const item of cases) {
), ),
).toBe(true) ).toBe(true)
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([ expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning }, { role: "assistant", content: response.text, reasoning: response.reasoning },
]) ])
+48 -29
View File
@@ -18,7 +18,6 @@ import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks" import { deltaChunk, usageChunk } from "../lib/openai-chunks"
@@ -43,7 +42,11 @@ const request = LLM.request({
describe("OpenAI Chat route", () => { describe("OpenAI Chat route", () => {
it.effect("prepares OpenAI Chat payload", () => it.effect("prepares OpenAI Chat payload", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(request) // 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
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
model: "gpt-4o-mini", model: "gpt-4o-mini",
@@ -61,7 +64,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () => it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -84,7 +87,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () => it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -102,7 +105,7 @@ describe("OpenAI Chat route", () => {
it.effect("writes reasoning to a configured custom field on every assistant message", () => it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [ messages: [
@@ -128,7 +131,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects reasoning fields that conflict with assistant message fields", () => it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }), model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
@@ -141,7 +144,7 @@ describe("OpenAI Chat route", () => {
it.effect("maps OpenAI provider options to Chat options", () => it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think", prompt: "think",
@@ -156,7 +159,7 @@ describe("OpenAI Chat route", () => {
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
prompt: "think", prompt: "think",
@@ -250,7 +253,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares assistant tool-call and tool-result messages", () => it.effect("prepares assistant tool-call and tool-result messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_result", id: "req_tool_result",
model, model,
@@ -288,7 +291,7 @@ describe("OpenAI Chat route", () => {
it.effect("preserves structured tool errors for the model", () => it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } } const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -308,7 +311,7 @@ describe("OpenAI Chat route", () => {
it.effect("continues image tool results as vision input without base64 text", () => it.effect("continues image tool results as vision input without base64 text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -352,7 +355,7 @@ describe("OpenAI Chat route", () => {
it.effect("orders parallel tool responses before one aggregated vision message", () => it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -402,7 +405,7 @@ describe("OpenAI Chat route", () => {
it.effect("aggregates consecutive tool images with a following system update", () => it.effect("aggregates consecutive tool images with a following system update", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -443,7 +446,7 @@ describe("OpenAI Chat route", () => {
it.effect("appends system updates without replacing multipart user content", () => it.effect("appends system updates without replacing multipart user content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -471,7 +474,7 @@ describe("OpenAI Chat route", () => {
] as const) ] as const)
it.effect(`rejects ${name}`, () => it.effect(`rejects ${name}`, () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }), LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip) ).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/) expect(error.message).toMatch(/does not support|does not match|valid base64/)
@@ -480,7 +483,7 @@ describe("OpenAI Chat route", () => {
it.effect("rejects oversized image input", () => it.effect("rejects oversized image input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -498,7 +501,7 @@ describe("OpenAI Chat route", () => {
it.effect("prepares raw and data URL image media as vision input", () => it.effect("prepares raw and data URL image media as vision input", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
id: "req_media", id: "req_media",
model, model,
@@ -525,7 +528,7 @@ describe("OpenAI Chat route", () => {
it.effect("lowers reasoning-only assistant history", () => it.effect("lowers reasoning-only assistant history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
id: "req_reasoning", id: "req_reasoning",
model, model,
@@ -616,7 +619,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: field }, openai: { reasoningField: field },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
} }
}), }),
@@ -642,7 +647,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "vendor_reasoning" }, openai: { reasoningField: "vendor_reasoning" },
}) })
const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
}), }),
) )
@@ -685,7 +692,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details }, openai: { reasoningField: "reasoning", reasoningDetails: details },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ {
role: "assistant", role: "assistant",
@@ -728,7 +737,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: details }, openai: { reasoningDetails: details },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}), }),
) )
@@ -753,7 +764,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningField: "reasoning", reasoningDetails: details }, openai: { reasoningField: "reasoning", reasoningDetails: details },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details }, { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
]) ])
@@ -826,7 +839,9 @@ describe("OpenAI Chat route", () => {
openai: { reasoningDetails: [] }, openai: { reasoningDetails: [] },
}) })
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}), }),
) )
@@ -874,7 +889,9 @@ describe("OpenAI Chat route", () => {
response.events.findIndex(LLMEvent.is.textStart), response.events.findIndex(LLMEvent.is.textStart),
) )
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged }, { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
]) ])
@@ -901,7 +918,9 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}), }),
) )
@@ -931,7 +950,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 } 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 second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* compileRequest( const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -960,7 +979,7 @@ describe("OpenAI Chat route", () => {
it.effect("retains scalar replay for mixed structured reasoning parts", () => it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () { Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 } const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* compileRequest( const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -985,7 +1004,7 @@ describe("OpenAI Chat route", () => {
it.effect("replays native scalar reasoning alongside native details", () => it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () { Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }] const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* compileRequest( const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -3,7 +3,6 @@ import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src" import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -53,7 +52,7 @@ const providerFamilies = [
describe("OpenAI-compatible Chat route", () => { describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () => it.effect("prepares generic Chat target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
toolChoice: ToolChoice.make({ type: "required" }), toolChoice: ToolChoice.make({ type: "required" }),
@@ -128,7 +127,7 @@ describe("OpenAI-compatible Chat route", () => {
it.effect("matches AI SDK compatible basic request body fixture", () => it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(request) const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
model: "deepseek-chat", model: "deepseek-chat",
@@ -144,23 +143,9 @@ describe("OpenAI-compatible Chat route", () => {
}), }),
) )
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () => it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_parity", id: "req_tool_parity",
model, model,
@@ -7,7 +7,6 @@ import { OpenResponses } from "../../src/protocols/open-responses"
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses" import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
import { OpenAIResponses } from "../../src/protocols/openai-responses" import { OpenAIResponses } from "../../src/protocols/openai-responses"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http" import { fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse" import { sseEvents } from "../lib/sse"
@@ -24,7 +23,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
provider: "example", provider: "example",
}).model("example-model") }).model("example-model")
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
system: "You are concise.", system: "You are concise.",
@@ -62,7 +61,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key", apiKey: "test-key",
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
}).model("example-model") }).model("example-model")
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }), LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
).pipe(Effect.flip) ).pipe(Effect.flip)
@@ -77,7 +76,7 @@ describe("Open Responses-compatible route", () => {
apiKey: "test-key", apiKey: "test-key",
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
}).model("example-model") }).model("example-model")
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -103,7 +102,7 @@ describe("Open Responses-compatible route", () => {
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}).model("example-model") }).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." })) const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
reasoning: { effort: "low" }, reasoning: { effort: "low" },
@@ -14,7 +14,6 @@ import {
Usage, Usage,
} from "../../src" } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai" import * as XAI from "../../src/providers/xai"
@@ -57,7 +56,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
describe("OpenAI Responses route", () => { describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses target", () => it.effect("prepares OpenAI Responses target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest(request) const prepared = yield* LLMClient.prepare(request)
expect(prepared.body).toEqual({ expect(prepared.body).toEqual({
model: "gpt-4.1-mini", model: "gpt-4.1-mini",
@@ -75,7 +74,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers the hosted OpenAI image generation tool", () => it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
prompt: "Show me a rooftop garden.", prompt: "Show me a rooftop garden.",
@@ -93,7 +92,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects invalid hosted image generation options locally", () => it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model, model,
prompt: "Show me a rooftop garden.", prompt: "Show me a rooftop garden.",
@@ -110,7 +109,7 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } }) const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
const prepared = yield* compileRequest(input) const prepared = yield* LLMClient.prepare(input)
expect(prepared.body).toMatchObject({ service_tier: "priority" }) expect(prepared.body).toMatchObject({ service_tier: "priority" })
expect(prepared.body).not.toHaveProperty("serviceTier") expect(prepared.body).not.toHaveProperty("serviceTier")
@@ -119,7 +118,7 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () => it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
) )
@@ -129,7 +128,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits unsupported semantic service tiers", () => it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
) )
@@ -139,7 +138,7 @@ describe("OpenAI Responses route", () => {
it.effect("flattens top-level object unions in function schemas", () => it.effect("flattens top-level object unions in function schemas", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLMRequest.update(request, { LLMRequest.update(request, {
tools: [ tools: [
ToolDefinition.make({ ToolDefinition.make({
@@ -192,7 +191,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers chronological system updates to escaped user wrappers in order", () => it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -218,7 +217,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares OpenAI Responses WebSocket target", () => it.effect("prepares OpenAI Responses WebSocket target", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLMRequest.update(request, { LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -396,7 +395,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares function call and function output input items", () => it.effect("prepares function call and function output input items", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_result", id: "req_tool_result",
model, model,
@@ -433,7 +432,7 @@ describe("OpenAI Responses route", () => {
content: [], content: [],
structured: {}, structured: {},
} }
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -454,7 +453,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps primitive tool errors as plain text", () => it.effect("keeps primitive tool errors as plain text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -470,7 +469,7 @@ describe("OpenAI Responses route", () => {
it.effect("keeps non-JSON tool errors as plain text", () => it.effect("keeps non-JSON tool errors as plain text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -488,7 +487,7 @@ describe("OpenAI Responses route", () => {
// image data is not JSON-stringified into `function_call_output.output`. // image data is not JSON-stringified into `function_call_output.output`.
it.effect("lowers image tool-result content as structured input_image items", () => it.effect("lowers image tool-result content as structured input_image items", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_tool_result_image", id: "req_tool_result_image",
model, model,
@@ -517,7 +516,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers single-image tool-result content as structured input_image array", () => it.effect("lowers single-image tool-result content as structured input_image array", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_tool_result_image_only", id: "req_tool_result_image_only",
model, model,
@@ -541,7 +540,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers PDF tool-result content as structured input_file array", () => it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_tool_result_pdf", id: "req_tool_result_pdf",
model, model,
@@ -576,7 +575,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for PDF tool results", () => it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: xaiModel, model: xaiModel,
messages: [ messages: [
@@ -611,7 +610,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported media in tool-result content with a clear error", () => it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_tool_result_unsupported_media", id: "req_tool_result_unsupported_media",
model, model,
@@ -634,7 +633,7 @@ describe("OpenAI Responses route", () => {
it.effect("prepares the composed native continuation request", () => it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
continuationRequest({ continuationRequest({
id: "req_native_continuation_openai", id: "req_native_continuation_openai",
model, model,
@@ -676,7 +675,7 @@ describe("OpenAI Responses route", () => {
it.effect("maps OpenAI provider options to Responses options", () => it.effect("maps OpenAI provider options to Responses options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
@@ -701,7 +700,7 @@ describe("OpenAI Responses route", () => {
it.effect("accepts the full ResponseIncludable union", () => it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
prompt: "hi", prompt: "hi",
@@ -723,7 +722,7 @@ describe("OpenAI Responses route", () => {
it.effect("filters unknown includable values out of the include array", () => it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
prompt: "hi", prompt: "hi",
@@ -740,7 +739,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an explicit empty include as no include at all", () => it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }), LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
) )
@@ -750,7 +749,7 @@ describe("OpenAI Responses route", () => {
it.effect("treats an all-invalid include as no include at all", () => it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
) )
@@ -760,7 +759,7 @@ describe("OpenAI Responses route", () => {
it.effect("omits include when no include is set", () => it.effect("omits include when no include is set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }), LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
) )
@@ -774,7 +773,7 @@ describe("OpenAI Responses route", () => {
// reasoningSummary: "auto" by default. Without `include`, a follow-up // reasoningSummary: "auto" by default. Without `include`, a follow-up
// turn cannot replay reasoning state, so the facade also opts into // turn cannot replay reasoning state, so the facade also opts into
// `reasoning.encrypted_content` automatically. // `reasoning.encrypted_content` automatically.
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi", prompt: "hi",
@@ -789,7 +788,7 @@ describe("OpenAI Responses route", () => {
it.effect("lets callers opt out of the GPT-5 default include", () => it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi", prompt: "hi",
@@ -803,7 +802,7 @@ describe("OpenAI Responses route", () => {
it.effect("request OpenAI provider options override route defaults", () => it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: OpenAI.configure({ model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/", baseURL: "https://api.openai.test/v1/",
@@ -935,7 +934,9 @@ describe("OpenAI Responses route", () => {
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
role: "assistant", role: "assistant",
@@ -1269,7 +1270,7 @@ describe("OpenAI Responses route", () => {
it.effect("preserves assistant content order around reasoning items", () => it.effect("preserves assistant content order around reasoning items", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_reasoning_order", id: "req_reasoning_order",
model, model,
@@ -1307,7 +1308,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored reasoning items by id", () => it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -1329,7 +1330,7 @@ describe("OpenAI Responses route", () => {
it.effect("references stored provider-executed hosted tool results by id", () => it.effect("references stored provider-executed hosted tool results by id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -1366,7 +1367,7 @@ describe("OpenAI Responses route", () => {
it.effect("continues stateless hosted image generation with the generated image", () => it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () { Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" }) const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [
@@ -1407,7 +1408,7 @@ describe("OpenAI Responses route", () => {
it.effect("joins streamed summary blocks into one continuation reasoning item", () => it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_multi_summary_continuation", id: "req_multi_summary_continuation",
model, model,
@@ -1444,7 +1445,7 @@ describe("OpenAI Responses route", () => {
it.effect("skips non-persisted reasoning ids without encrypted state", () => it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_reasoning_without_encrypted_state", id: "req_reasoning_without_encrypted_state",
model, model,
@@ -1761,7 +1762,7 @@ describe("OpenAI Responses route", () => {
it.effect("lowers user image and PDF content", () => it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
id: "req_media", id: "req_media",
model, model,
@@ -1792,7 +1793,7 @@ describe("OpenAI Responses route", () => {
it.effect("uses xAI inline file encoding for user PDFs", () => it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ LLM.request({
model: xaiModel, model: xaiModel,
messages: [ messages: [
@@ -1824,7 +1825,7 @@ describe("OpenAI Responses route", () => {
it.effect("rejects unsupported user media content", () => it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* LLMClient.prepare(
LLM.request({ LLM.request({
id: "req_media", id: "req_media",
model, model,
+7 -184
View File
@@ -1,8 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src" import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http" import { fixedResponse } from "../lib/http"
@@ -20,141 +19,20 @@ describe("OpenRouter", () => {
}) })
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." })) const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
expect(prepared.route).toBe("openrouter") expect(prepared.route).toBe("openrouter")
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
model: "openai/gpt-4o-mini", model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }], messages: [{ role: "user", content: "Say hello." }],
stream: true, stream: true,
usage: { include: true },
}) })
}), }),
) )
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () => it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: OpenRouter.configure({ model: OpenRouter.configure({
apiKey: "test-key", apiKey: "test-key",
@@ -163,13 +41,6 @@ describe("OpenRouter", () => {
usage: true, usage: true,
reasoning: { effort: "high" }, reasoning: { effort: "high" },
promptCacheKey: "session_123", promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
}, },
}, },
}).model("anthropic/claude-3.7-sonnet:thinking"), }).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -181,54 +52,10 @@ describe("OpenRouter", () => {
usage: { include: true }, usage: { include: true },
reasoning: { effort: "high" }, reasoning: { effort: "high" },
prompt_cache_key: "session_123", prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
}) })
}), }),
) )
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () => it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6") const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -273,10 +100,9 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 }, { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 }, { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
] ]
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({ LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [ messages: [
Message.assistant([ Message.assistant([
{ {
@@ -307,10 +133,9 @@ describe("OpenRouter", () => {
{ type: "reasoning.encrypted", id: "state", data: "opaque" }, { type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" }, { type: "reasoning.encrypted", id: "state", data: "opaque" },
] ]
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({ LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [ messages: [
Message.assistant({ Message.assistant({
type: "reasoning", type: "reasoning",
@@ -333,10 +158,9 @@ describe("OpenRouter", () => {
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" }, { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" }, { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
] ]
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({ LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [ messages: [
Message.assistant({ Message.assistant({
type: "reasoning", type: "reasoning",
@@ -355,10 +179,9 @@ describe("OpenRouter", () => {
it.effect("omits scalar reasoning without continuation details", () => it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({ LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"), model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })], messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}), }),
) )
@@ -3,8 +3,7 @@ import { Effect } from "effect"
import { LLM } from "../src" import { LLM } from "../src"
import { OpenAIChat } from "../src/protocols" import { OpenAIChat } from "../src/protocols"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
import { Auth } from "../src/route" import { Auth, LLMClient } from "../src/route"
import { compileRequest } from "../src/route/client"
import { it } from "./lib/effect" import { it } from "./lib/effect"
describe("tool schema projections", () => { describe("tool schema projections", () => {
@@ -80,7 +79,7 @@ describe("tool schema projections", () => {
const model = OpenAIChat.route const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
const prepared = yield* compileRequest( const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ LLM.request({
model, model,
prompt: "Use the tool.", prompt: "Use the tool.",
-1
View File
@@ -59,7 +59,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz", "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*", "@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4", "@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
+1 -1
View File
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk().directory)
sdk() sdk()
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .api.session.fork({ sessionID, messageID: item.id })
.then((forked) => { .then((forked) => {
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id }) prompt.set(restored, undefined, { dir, id: forked.id })
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query" import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -55,10 +54,7 @@ export function TabNavItem(props: {
if (!session) return if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? []) return projectForSession(session, serverCtx()?.projects.list() ?? [])
}) })
const title = createMemo(() => { const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
})
const projectName = createMemo(() => { const projectName = createMemo(() => {
const session = props.session() const session = props.session()
@@ -147,7 +143,7 @@ export function TabNavItem(props: {
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session() const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title ?? "" titleEl.textContent = session.title
setEditing(true) setEditing(true)
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -306,7 +302,7 @@ export function TabNavItem(props: {
}} }}
data={{ data={{
projectName: projectName(), projectName: projectName(),
title: title(), title: props.session()?.title,
path: previewPath(), path: previewPath(),
serverName: serverLabel(), serverName: serverLabel(),
}} }}
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
api.list().then((projects) => { api.list().then((projects) => {
return projects return projects
.filter((p) => !!p?.id) .filter((p) => !!p?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.map(normalizeProjectInfo)
.slice() .slice()
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
}), }),
@@ -168,7 +168,6 @@ export function sanitizeProject(project: Project) {
export function normalizeProjectInfo(project: Project | CurrentProject): Project { export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return { return {
...project, ...project,
worktree: "canonical" in project ? project.canonical : project.worktree,
vcs: project.vcs === "git" ? "git" : undefined, vcs: project.vcs === "git" ? "git" : undefined,
} }
} }
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server" import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers" import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller" import type { HomeController } from "./home-controller"
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return [] if (!value) return []
return sessions.data return sessions.data
.searchRecords() .searchRecords()
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value)) .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
}) })
const active = createMemo(() => { const active = createMemo(() => {
const records = results() const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open" import { shouldOpenSessionInBackground } from "../home-session-open"
import { import {
HomeSessionStatusController, HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean selected: boolean
}, },
) { ) {
const title = createMemo(() => displayLabel(props.record.session)) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record) const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
} }
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => displayLabel(props.record.session)) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName const showProjectName = () => props.showProjectName() && props.record.projectName
return ( return (
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router" import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers" import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
}): JSX.Element => { }): JSX.Element => {
const title = () => displayLabel(props.session) const title = () => sessionTitle(props.session.title)
return ( return (
<A <A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={ fallback={
<Tooltip <Tooltip
placement={props.mobile ? "bottom" : "right"} placement={props.mobile ? "bottom" : "right"}
value={displayLabel(props.session)} value={sessionTitle(props.session.title)}
gutter={10} gutter={10}
class="min-w-0 w-full" class="min-w-0 w-full"
> >
+30 -51
View File
@@ -57,7 +57,7 @@ import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useSettingsCommand } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
@@ -70,11 +70,11 @@ import {
createSessionComposerRegionController, createSessionComposerRegionController,
SessionComposerRegion, SessionComposerRegion,
} from "@/pages/session/composer" } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers" import { createOpenReviewFile, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionController } from "@/pages/session/session-controller"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { import {
clampSessionPanelWidth, clampSessionPanelWidth,
@@ -101,7 +101,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage" import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string } type FollowupItem = FollowupDraft & { id: string }
@@ -366,15 +365,28 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const command = useCommand() const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)")
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
const canReview = createMemo(() => !!sync().project)
const session = createSessionController({
review: isDesktop,
hasReview: canReview,
fileBrowser: (sessionID) => newSessionDesign() && isDesktop() && !!sessionID,
})
const params = session.identity.params
const sessionKey = session.identity.sessionKey
const workspaceKey = session.identity.workspaceKey
const tabs = session.layout.tabs
const view = session.layout.view
const reviewMode = () => view().review.mode() ?? "git" const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file() const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey) const sessionOwnership = session.ownership
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) const info = session.data.info
const isChildSession = session.data.isChild
const revertMessageID = session.data.revertMessageID
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!prompt.ready()) return
@@ -433,8 +445,8 @@ export default function Page() {
const current = tabs().tabs() const current = tabs().tabs()
if (current.all.length > 0 || current.active) return if (current.all.length > 0 || current.active) return
const all = normalizeTabs(from.all) const all = session.tabs.normalizeAll(from.all)
const active = from.active ? normalizeTab(from.active) : undefined const active = from.active ? session.tabs.normalize(from.active) : undefined
tabs().setAll(all) tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0]) tabs().setActive(active && all.includes(active) ? active : all[0])
@@ -445,7 +457,6 @@ export default function Page() {
), ),
) )
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing() const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
@@ -510,46 +521,16 @@ export default function Page() {
}), }),
) )
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
function normalizeTabs(list: string[]) {
const seen = new Set<string>()
const next: string[] = []
for (const item of list) {
const value = normalizeTab(item)
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
return next
}
const openReviewPanel = () => { const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
} }
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const activeTab = session.tabs.activeTab
const isChildSession = createMemo(() => !!info()?.parentID) const activeFileTab = session.tabs.activeFileTab
const canReview = createMemo(() => !!sync().project) const timeline = createTimelineModel({ session })
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: canReview,
})
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const historyLoading = timeline.history.loading const historyLoading = timeline.history.loading
const historyMore = timeline.history.more const historyMore = timeline.history.more
const lastUserMessage = timeline.lastUserMessage const lastUserMessage = timeline.lastUserMessage
const messages = timeline.messages
const messagesReady = timeline.ready const messagesReady = timeline.ready
const sessionSync = timeline.resource const sessionSync = timeline.resource
const userMessages = timeline.userMessages const userMessages = timeline.userMessages
@@ -1138,11 +1119,10 @@ export default function Page() {
useComposerCommands() useComposerCommands()
useSessionCommands({ useSessionCommands({
session,
navigateMessageByOffset, navigateMessageByOffset,
setActiveMessage, setActiveMessage,
focusInput, focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
}) })
command.register("session-palette", () => [ command.register("session-palette", () => [
{ {
@@ -1692,8 +1672,6 @@ export default function Page() {
}) })
} }
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => { const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const session = target.session.get(sessionID) const session = target.session.get(sessionID)
if (!session) return if (!session) return
@@ -1754,7 +1732,7 @@ export default function Page() {
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
const id = params.id const id = params.id
if (!id) return false if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() return settings.general.followup() === "queue" && session.data.working() && !composer.blocked() && !isChildSession()
}) })
const followupText = (item: FollowupDraft) => { const followupText = (item: FollowupDraft) => {
@@ -1932,7 +1910,7 @@ export default function Page() {
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (isChildSession()) return if (isChildSession()) return
if (composer.blocked()) return if (composer.blocked()) return
if (busy(sessionID)) return if (session.data.working()) return
void sendFollowup(sessionID, item.id) void sendFollowup(sessionID, item.id)
}) })
@@ -2080,6 +2058,7 @@ export default function Page() {
<Show when={messagesReady() ? params.id : undefined} keyed> <Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => ( {(_id) => (
<MessageTimeline <MessageTimeline
session={session}
actions={actions} actions={actions}
scroll={ui.scroll} scroll={ui.scroll}
onResumeScroll={resumeScroll} onResumeScroll={resumeScroll}
@@ -2157,7 +2136,7 @@ export default function Page() {
: undefined, : undefined,
onResponseSubmit: resumeScroll, onResponseSubmit: resumeScroll,
openParent: () => { openParent: () => {
const id = info()?.parentID const id = session.data.parentID()
if (!id) return if (!id) return
navigate( navigate(
params.serverKey params.serverKey
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
id,
sessionID: "session",
role: "user",
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
id: "msg_2",
sessionID: "session",
role: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
describe("session controller invariants", () => {
test("normalizes file tabs once while preserving non-file tabs and order", () => {
const normalize = (tab: string) => normalizeSessionTab(tab, (value) => value.toLowerCase())
expect(normalizeSessionTabs(["review", "file://SRC/A.TS", "file://src/a.ts", "context"], normalize)).toEqual([
"review",
"file://src/a.ts",
"context",
])
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant, user("msg_3"), user("msg_5")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleSessionUserMessages(users, "msg_3").map((message) => message.id)).toEqual(["msg_1"])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
test("rejects work captured by a previous session", () => {
createRoot((dispose) => {
const [key, setKey] = createSignal("session-a")
const ownership = createSessionOwnership(key)
const captured = ownership.capture()
let ran = false
setKey("session-b")
expect(captured.current()).toBe(false)
expect(captured.run(() => (ran = true))).toBeUndefined()
expect(ran).toBe(false)
dispose()
})
})
})
@@ -0,0 +1,101 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
const file = useFile()
const sync = useSync()
const layout = useSessionLayout()
const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => {
const id = sessionID()
return id ? sync().session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? sync().session.get(id) : undefined
})
const status = createMemo(() => {
const id = sessionID()
return id ? (sync().data.session_status[id] ?? idle) : idle
})
const messages = createMemo(() => {
const id = sessionID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
() => selectVisibleSessionUserMessages(userMessages(), revertMessageID()),
emptyUserMessages,
{ equals: same },
)
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
})
return {
identity: {
params: layout.params,
sessionID,
sessionKey: layout.sessionKey,
workspaceKey: layout.workspaceKey,
},
data: {
info,
parent,
parentID,
isChild: createMemo(() => !!parentID()),
status,
working: createMemo(() => {
const id = sessionID()
return id ? sync().data.session_working(id) : false
}),
revertMessageID,
},
history: {
messages,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
},
layout: {
tabs: layout.tabs,
view: layout.view,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
...tabs,
normalize: normalizeTab,
normalizeAll: (values: string[]) => normalizeSessionTabs(values, normalizeTab),
},
}
}
export type SessionController = ReturnType<typeof createSessionController>
@@ -0,0 +1,19 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
return normalizeFileTab(tab)
}
export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) => string) {
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
@@ -0,0 +1,26 @@
export function timelineChildTitle(input: {
parentID?: string
taskDescription?: string
title?: string
fallback: string
}) {
if (!input.parentID) return input.title ?? ""
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
describe("timeline controller", () => {
test("projects child titles from task descriptions and session fallbacks", () => {
expect(timelineChildTitle({ title: "Root", fallback: "New session" })).toBe("Root")
expect(
timelineChildTitle({ parentID: "parent", taskDescription: "Investigate timeline", fallback: "New session" }),
).toBe("Investigate timeline")
expect(
timelineChildTitle({ parentID: "parent", title: "Fallback (@build subagent)", fallback: "New session" }),
).toBe("Fallback")
expect(timelineChildTitle({ parentID: "parent", fallback: "New session" })).toBe("New session")
})
test("collects the removed session and all descendants", () => {
const removed = timelineRemovedSessionIDs(
[
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "sibling", parentID: "root" },
{ id: "unrelated" },
],
"root",
)
expect([...removed]).toEqual(["root", "child", "grandchild", "sibling"])
})
})
@@ -0,0 +1,328 @@
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return undefined
const value = part.state.input?.description
if (typeof value === "string" && value) return value
return undefined
}
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
const navigate = useNavigate()
const serverSDK = useServerSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const params = input.session.identity.params
const sessionKey = input.session.identity.sessionKey
const sessionID = input.session.identity.sessionID
const status = input.session.data.status
const messages = input.session.history.messages
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = sync().data.session_message[id] ?? []
return boundary ? projected.filter((message) => message.id < boundary) : projected
})
const info = input.session.data.info
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = input.session.data.parentID
const parent = input.session.data.parent
const parentMessages = createMemo(() => {
const id = parentID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parts(message.id))
.map((item) => taskDescription(item, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
const errorMessage = (error: unknown) => {
if (error && typeof error === "object" && "data" in error) {
const data = error.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
}
if (error instanceof Error) return error.message
return language.t("common.requestFailed")
}
const rename = async (title: string) => {
const id = sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await sdk()
.api.session.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
return false
})
setPending("rename", false)
if (!success) return false
sync().set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
return true
}
const share = async () => {
const id = sessionID()
if (!id || pending.share || !shareEnabled()) return
setPending("share", true)
await serverSDK()
.client.session.share({ sessionID: id })
.catch((error) => console.error("Failed to share session", error))
setPending("share", false)
}
const unshare = async () => {
const id = sessionID()
if (!id || pending.unshare || !shareEnabled()) return
setPending("unshare", true)
await serverSDK()
.client.session.unshare({ sessionID: id })
.catch((error) => console.error("Failed to unshare session", error))
setPending("unshare", false)
}
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
if (params.serverKey)
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
navigate(`/${params.dir}/session`)
}
const archive = async (id: string) => {
const session = sync().session.get(id)
if (!session || (await sdk().protocol) !== "v1") return
const index = sync().data.session.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sync().data.session[index + 1] ?? sync().data.session[index - 1])
await sdk()
.client.session.update({ sessionID: id, directory: sdk().directory, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((item) => item.id === id)
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync().session.evict(id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [id] })
})
.catch((error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }))
}
const remove = async (id: string) => {
const session = sync().session.get(id)
if (!session) return false
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await sdk()
.api.session.remove({ sessionID: id })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = timelineRemovedSessionIDs(sync().data.session, id)
void navigateAfterRemoval(id, session.parentID, next?.id)
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
removed.forEach((sessionID) => sync().session.evict(sessionID))
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description || sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
return {
data: {
sessionKey,
sessionID,
status,
titleValue,
titleLabel,
shareUrl,
shareEnabled,
parentID,
parentTitle,
childTitle,
showHeader,
parts,
part,
projection,
newLayoutDesigns: settings.general.newLayoutDesigns,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
share: () => pending.share,
unshare: () => pending.unshare,
},
action: {
rename,
share,
unshare,
archive,
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = parentID()
if (id) navigate(href(id))
},
viewShare: () => {
const url = shareUrl()
if (url) platform.openLink(url)
},
copyShareUrl: async () => {
const url = shareUrl()
if (!url) return
await navigator.clipboard.writeText(url).then(
() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
(error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
)
},
},
}
}
export type TimelineController = ReturnType<typeof createTimelineController>
@@ -11,10 +11,8 @@ import {
type Accessor, type Accessor,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -35,8 +33,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry" import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
@@ -45,43 +41,22 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { import type { AssistantMessage, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff" import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { scheduleConnectedMeasure } from "./measure" import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items" import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = [] const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = [] const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }> type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }> type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -89,14 +64,6 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
const timelineFallbackItemSize = 60 const timelineFallbackItemSize = 60
const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>() const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]") const nested = current?.closest("[data-scrollable]")
@@ -237,7 +204,8 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
) )
} }
export function MessageTimeline(props: { type MessageTimelineProps = {
session: TimelineSessionSource
actions?: UserActions actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean } scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void onResumeScroll: () => void
@@ -257,94 +225,42 @@ export function MessageTimeline(props: {
setRevealMessage?: (fn: (id: string) => void) => void setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}) { }
let touchGesture: number | undefined
const navigate = useNavigate() export function MessageTimeline(props: MessageTimelineProps) {
const serverSDK = useServerSDK() const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
const sdk = useSDK() return (
const sync = useSync() <MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
const settings = useSettings() )
const tabs = useTabs() }
const dialog = useDialog()
function MessageTimelineView(
props: MessageTimelineProps & {
data: TimelineController["data"]
action: TimelineController["action"]
pending: TimelineController["pending"]
},
) {
let touchGesture: number | undefined
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const ownerSessionKey = props.data.sessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey) const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom() const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
const platform = usePlatform()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>() const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = createMemo(() => params.id) const sessionID = props.data.sessionID
const sessionStatus = createMemo(() => { const sessionStatus = props.data.status
const id = sessionID() const titleLabel = props.data.titleLabel
if (!id) return idle const shareUrl = props.data.shareUrl
return sync().data.session_status[id] ?? idle const shareEnabled = props.data.shareEnabled
}) const parentID = props.data.parentID
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) const parentTitle = props.data.parentTitle
const projectedMessages = createMemo(() => { const childTitle = props.data.childTitle
const id = sessionID() const showHeader = props.data.showHeader
if (!id) return [] const getMsgParts = props.data.parts
const visible = new Set(props.userMessages.map((message) => message.id)) const getMsgPart = props.data.part
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id const projection = props.data.projection
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync().session.get(id)
})
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync().session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => getMsgParts(message.id))
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey const lastAssistantGroupKey = projection.lastAssistantGroupKey
@@ -534,9 +450,9 @@ export function MessageTimeline(props: {
virtualizer.scrollToEnd() virtualizer.scrollToEnd()
} }
let measuredSessionKey = sessionKey() let measuredSessionKey = props.data.sessionKey()
createEffect(() => { createEffect(() => {
const key = sessionKey() const key = props.data.sessionKey()
timelineRows().length timelineRows().length
if (measuredSessionKey !== key) { if (measuredSessionKey !== key) {
measuredSessionKey = key measuredSessionKey = key
@@ -649,88 +565,6 @@ export function MessageTimeline(props: {
props.setScrollRef(undefined) props.setScrollRef(undefined)
}) })
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => { const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection() const selection = window.getSelection()
if (!selection) return if (!selection) return
@@ -742,7 +576,7 @@ export function MessageTimeline(props: {
createEffect( createEffect(
on( on(
sessionKey, props.data.sessionKey,
() => () =>
setTitle({ setTitle({
draft: "", draft: "",
@@ -755,18 +589,6 @@ export function MessageTimeline(props: {
), ),
) )
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => { const openTitleEditor = () => {
if (!sessionID() || parentID()) return if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" }) setTitle({ editing: true, draft: titleLabel() ?? "" })
@@ -778,194 +600,12 @@ export function MessageTimeline(props: {
} }
const closeTitleEditor = () => { const closeTitleEditor = () => {
if (titleMutation.isPending) return if (props.pending.rename()) return
setTitle("editing", false) setTitle("editing", false)
} }
const saveTitleEditor = () => { const saveTitleEditor = async () => {
const id = sessionID() if (await props.action.rename(title.draft)) setTitle("editing", false)
if (!id) return
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) {
setTitle("editing", false)
return
}
titleMutation.mutate({ id, title: next })
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(href(parentID))
return
}
if (nextSessionID) {
navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync().session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return false
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync().data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync().set(
produce((draft) => {
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
for (const id of removed) {
sync().session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
} }
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
@@ -1044,7 +684,7 @@ export function MessageTimeline(props: {
const defaultOpen = createMemo(() => { const defaultOpen = createMemo(() => {
const item = part() const item = part()
if (!item) return if (!item) return
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
}) })
return ( return (
@@ -1057,7 +697,7 @@ export function MessageTimeline(props: {
message={message()} message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={settings.general.newLayoutDesigns()} useV2Actions={props.data.newLayoutDesigns()}
defaultOpen={defaultOpen()} defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)} onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -1120,8 +760,8 @@ export function MessageTimeline(props: {
<div <div
classList={{ classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true, "shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": settings.general.newLayoutDesigns(), "border-[0.5px]": props.data.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(), border: !props.data.newLayoutDesigns(),
}} }}
> >
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
@@ -1156,7 +796,7 @@ export function MessageTimeline(props: {
if (m?.role === "user") return m if (m?.role === "user") return m
}) })
const messageComments = createMemo(() => { const messageComments = createMemo(() => {
if (!settings.general.newLayoutDesigns()) return [] if (!props.data.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []) return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
}) })
return ( return (
@@ -1169,7 +809,7 @@ export function MessageTimeline(props: {
message={message()} message={message()}
parts={getMsgParts(userMessageRow().userMessageID)} parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions} actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()} useV2Actions={props.data.newLayoutDesigns()}
comments={messageComments()} comments={messageComments()}
/> />
</div> </div>
@@ -1217,7 +857,7 @@ export function MessageTimeline(props: {
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5"> <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow <TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading} reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()} showReasoningSummaries={props.data.showReasoningSummaries()}
/> />
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
@@ -1333,16 +973,16 @@ export function MessageTimeline(props: {
<div <div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out" class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{ classList={{
"bottom-8": settings.general.newLayoutDesigns(), "bottom-8": props.data.newLayoutDesigns(),
"bottom-6": !settings.general.newLayoutDesigns(), "bottom-6": !props.data.newLayoutDesigns(),
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump, "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump, "opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(), "scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(), "scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
}} }}
> >
<Show <Show
when={settings.general.newLayoutDesigns()} when={props.data.newLayoutDesigns()}
fallback={ fallback={
<button <button
type="button" type="button"
@@ -1405,22 +1045,22 @@ export function MessageTimeline(props: {
classList={{ classList={{
"sticky top-0 z-30": true, "sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]": "bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
settings.general.newLayoutDesigns(), props.data.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": "bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!settings.general.newLayoutDesigns(), !props.data.newLayoutDesigns(),
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
"pl-2.5": settings.general.newLayoutDesigns(), "pl-2.5": props.data.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !props.data.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
}} }}
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div <div
classList={{ classList={{
"flex items-center gap-1 min-w-0 flex-1": true, "flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !settings.general.newLayoutDesigns(), "pr-3": !props.data.newLayoutDesigns(),
}} }}
> >
<div class="flex items-center min-w-0 flex-1 w-full"> <div class="flex items-center min-w-0 flex-1 w-full">
@@ -1429,7 +1069,7 @@ export function MessageTimeline(props: {
type="button" type="button"
data-slot="session-title-parent" data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted" class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent} onClick={props.action.navigateParent}
> >
{parentTitle()} {parentTitle()}
</button> </button>
@@ -1450,8 +1090,8 @@ export function MessageTimeline(props: {
classList={{ classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true, "truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover": "w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(), props.data.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(), "grow-1 min-w-0": !props.data.newLayoutDesigns(),
}} }}
onClick={openTitleEditor} onClick={openTitleEditor}
> >
@@ -1465,15 +1105,14 @@ export function MessageTimeline(props: {
}} }}
data-slot="session-title-child" data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={props.pending.rename()}
classList={{ classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true, "block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(), "w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": "field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
}} }}
style={{ style={{
"--inline-input-shadow": settings.general.newLayoutDesigns() "--inline-input-shadow": props.data.newLayoutDesigns()
? "none" ? "none"
: "var(--shadow-xs-border-select)", : "var(--shadow-xs-border-select)",
}} }}
@@ -1501,17 +1140,17 @@ export function MessageTimeline(props: {
<div <div
classList={{ classList={{
"shrink-0 flex items-center": true, "shrink-0 flex items-center": true,
"gap-2": settings.general.newLayoutDesigns(), "gap-2": props.data.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(), "gap-3": !props.data.newLayoutDesigns(),
}} }}
> >
<SessionContextUsage <SessionContextUsage
placement="bottom" placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"} buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
/> />
<Show when={!parentID()}> <Show when={!parentID()}>
<Show <Show
when={settings.general.newLayoutDesigns()} when={props.data.newLayoutDesigns()}
fallback={ fallback={
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
@@ -1574,13 +1213,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}> <DropdownMenu.Item onSelect={() => void props.action.archive(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
@@ -1645,11 +1282,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}... {language.t("session.share.action.share")}...
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}> <MenuV2.Item onSelect={() => void props.action.archive(id)}>
{language.t("common.archive")} {language.t("common.archive")}
</MenuV2.Item> </MenuV2.Item>
<MenuV2.Separator /> <MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}> <MenuV2.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}... {language.t("common.delete")}...
</MenuV2.Item> </MenuV2.Item>
</MenuV2.Content> </MenuV2.Content>
@@ -1661,7 +1298,7 @@ export function MessageTimeline(props: {
open={share.open} open={share.open}
anchorRef={() => more} anchorRef={() => more}
placement="bottom-end" placement="bottom-end"
gutter={settings.general.newLayoutDesigns() ? 6 : 4} gutter={props.data.newLayoutDesigns() ? 6 : 4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) setShare("dismiss", null) if (open) setShare("dismiss", null)
@@ -1673,7 +1310,7 @@ export function MessageTimeline(props: {
data-component="popover-content" data-component="popover-content"
classList={{ classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]": "flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
settings.general.newLayoutDesigns(), props.data.newLayoutDesigns(),
}} }}
style={{ "min-width": "320px" }} style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
@@ -1693,7 +1330,7 @@ export function MessageTimeline(props: {
}} }}
> >
<Show <Show
when={settings.general.newLayoutDesigns()} when={props.data.newLayoutDesigns()}
fallback={ fallback={
<div class="flex flex-col p-3"> <div class="flex flex-col p-3">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -1714,10 +1351,10 @@ export function MessageTimeline(props: {
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={() => void props.action.share()}
disabled={shareMutation.isPending} disabled={props.pending.share()}
> >
{shareMutation.isPending {props.pending.share()
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -1737,10 +1374,10 @@ export function MessageTimeline(props: {
size="large" size="large"
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession} onClick={() => void props.action.unshare()}
disabled={unshareMutation.isPending} disabled={props.pending.unshare()}
> >
{unshareMutation.isPending {props.pending.unshare()
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -1748,8 +1385,8 @@ export function MessageTimeline(props: {
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={props.action.viewShare}
disabled={unshareMutation.isPending} disabled={props.pending.unshare()}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -1777,10 +1414,10 @@ export function MessageTimeline(props: {
<ButtonV2 <ButtonV2
variant="contrast" variant="contrast"
class="w-full" class="w-full"
onClick={shareSession} onClick={() => void props.action.share()}
disabled={shareMutation.isPending} disabled={props.pending.share()}
> >
{shareMutation.isPending {props.pending.share()
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</ButtonV2> </ButtonV2>
@@ -1806,7 +1443,7 @@ export function MessageTimeline(props: {
variant="ghost-muted" variant="ghost-muted"
icon={<IconV2 name="outline-copy" />} icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")} aria-label={language.t("session.share.copy.copyLink")}
onClick={copyShareUrl} onClick={() => void props.action.copyShareUrl()}
/> />
<IconButtonV2 <IconButtonV2
type="button" type="button"
@@ -1814,18 +1451,18 @@ export function MessageTimeline(props: {
variant="ghost-muted" variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />} icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")} aria-label={language.t("session.share.action.view")}
onClick={viewShare} onClick={props.action.viewShare}
disabled={unshareMutation.isPending} disabled={props.pending.unshare()}
/> />
</div> </div>
<div class="flex w-full"> <div class="flex w-full">
<ButtonV2 <ButtonV2
variant="outline" variant="outline"
class="w-full" class="w-full"
onClick={unshareSession} onClick={() => void props.action.unshare()}
disabled={unshareMutation.isPending} disabled={props.pending.unshare()}
> >
{unshareMutation.isPending {props.pending.unshare()
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</ButtonV2> </ButtonV2>
@@ -1,23 +1,24 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { same } from "@/utils/same" import type { SessionController } from "../session-controller"
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000 const sessionFreshness = 15_000
export function createTimelineModel(input: { export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
const serverSync = useServerSync() const serverSync = useServerSync()
const sync = useSync() const sync = useSync()
let refreshFrame: number | undefined let refreshFrame: number | undefined
let refreshTimer: number | undefined let refreshTimer: number | undefined
const [resource] = createResource( const [resource] = createResource(
() => input.sessionID(), () => input.session.identity.sessionID(),
(id) => { (id) => {
clearRefresh() clearRefresh()
if (!id) return if (!id) return
@@ -29,7 +30,7 @@ export function createTimelineModel(input: {
refreshFrame = undefined refreshFrame = undefined
refreshTimer = window.setTimeout(() => { refreshTimer = window.setTimeout(() => {
refreshTimer = undefined refreshTimer = undefined
if (input.sessionID() !== id) return if (input.session.identity.sessionID() !== id) return
untrack(() => { untrack(() => {
if (stale) void sync().session.sync(id, { force: true }) if (stale) void sync().session.sync(id, { force: true })
}) })
@@ -39,33 +40,21 @@ export function createTimelineModel(input: {
return sync().session.sync(id) return sync().session.sync(id)
}, },
) )
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => { const ready = createMemo(() => {
const id = input.sessionID() const id = input.session.identity.sessionID()
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id)) return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
}) })
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => { const more = createMemo(() => {
const id = input.sessionID() const id = input.session.identity.sessionID()
return id ? sync().session.history.more(id) : false return id ? sync().session.history.more(id) : false
}) })
const loading = createMemo(() => { const loading = createMemo(() => {
const id = input.sessionID() const id = input.session.identity.sessionID()
return id ? sync().session.history.loading(id) : false return id ? sync().session.history.loading(id) : false
}) })
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => { const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({ return loadOlderTimeline({
sessionID: input.sessionID, sessionID: input.session.identity.sessionID,
more, more,
loading, loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID), loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@@ -78,12 +67,12 @@ export function createTimelineModel(input: {
return { return {
history: { loadOlder, loading, more }, history: { loadOlder, loading, more },
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)), lastUserMessage: input.session.history.lastUserMessage,
messages, messages: input.session.history.messages,
ready, ready,
resource, resource,
userMessages, userMessages: input.session.history.userMessages,
visibleUserMessages, visibleUserMessages: input.session.history.visibleUserMessages,
} }
function clearRefresh() { function clearRefresh() {
@@ -94,19 +83,10 @@ export function createTimelineModel(input: {
} }
} }
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) { export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading) return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
} }
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export async function loadOlderTimeline(input: { export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined> sessionID: Accessor<string | undefined>
more: Accessor<boolean> more: Accessor<boolean>
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset instance.scrollOffset = offset
}) })
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove() route.remove()
document.body.append(route) document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3) await frames(3)
expect(calls).toEqual([[0, false]]) expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove() route.remove()
document.body.append(route) document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
@@ -13,19 +13,25 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { findLast } from "@opencode-ai/core/util/array" import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
identity: SessionController["identity"]
data: Pick<SessionController["data"], "info" | "revertMessageID">
history: Pick<SessionController["history"], "userMessages" | "visibleUserMessages">
layout: SessionController["layout"]
ownership: SessionController["ownership"]
tabs: Pick<SessionController["tabs"], "activeFileTab" | "closableTab">
}
export type SessionCommandContext = { export type SessionCommandContext = {
session: SessionCommandSource
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
} }
const withCategory = (category: string) => { const withCategory = (category: string) => {
@@ -49,15 +55,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const navigate = useNavigate() const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout() const params = actions.session.identity.params
const sessionOwnership = createSessionOwnership(sessionKey) const tabs = actions.session.layout.tabs
const view = actions.session.layout.view
const sessionOwnership = actions.session.ownership
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => { const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = sessionOwnership.capture() const owner = sessionOwnership.capture()
const value = await load() const value = await load()
owner.run(() => show(value)) owner.run(() => show(value))
} }
const runCommand = async <T,>(input: { const runCommand = async <T,>(input: {
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]> owner: ReturnType<SessionController["ownership"]["capture"]>
prompt: T prompt: T
request: () => Promise<unknown> request: () => Promise<unknown>
updatePrompt: (prompt: T) => void updatePrompt: (prompt: T) => void
@@ -68,39 +76,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
input.owner.run(input.updateViewport) input.owner.run(input.updateViewport)
} }
const info = () => { const info = actions.session.data.info
const id = params.id const activeFileTab = actions.session.tabs.activeFileTab
if (!id) return const closableTab = actions.session.tabs.closableTab
return sync().session.get(id)
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = settings.visibility.fileTree const shown = settings.visibility.fileTree
const messages = () => { const userMessages = actions.session.history.userMessages
const id = params.id const visibleUserMessages = actions.session.history.visibleUserMessages
if (!id) return []
return sync().data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}
const showAllFiles = () => { const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return if (layout.fileTree.tab() !== "changes") return
@@ -309,7 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const session = sdk().api.session const session = sdk().api.session
const directory = sdk().directory const directory = sdk().directory
const promptSession = prompt.capture() const promptSession = prompt.capture()
const revert = info()?.revert?.messageID const revert = actions.session.data.revertMessageID()
const messages = userMessages() const messages = userMessages()
const message = findLast(messages, (x) => !revert || x.id < revert) const message = findLast(messages, (x) => !revert || x.id < revert)
if (!message) return if (!message) return
@@ -338,7 +320,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const messages = userMessages() const messages = userMessages()
const promptSession = prompt.capture() const promptSession = prompt.capture()
const revertMessageID = info()?.revert?.messageID const revertMessageID = actions.session.data.revertMessageID()
if (!revertMessageID) return if (!revertMessageID) return
const next = messages.find((x) => x.id > revertMessageID) const next = messages.find((x) => x.id > revertMessageID)
@@ -26,6 +26,7 @@ function setup(
return new Response(undefined, { status: 204 }) return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) { if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({ return Response.json({
admittedSeq: 1,
id: "msg_1", id: "msg_1",
sessionID: "ses_1", sessionID: "ses_1",
timeCreated: 1, timeCreated: 1,
+6 -10
View File
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
const located = <T>(data: T, value?: { directory?: string }) => ({ const located = <T>(data: T, value?: { directory?: string }) => ({
location: { location: {
directory: directory(value) ?? "", directory: directory(value) ?? "",
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" }, project: { id: "", directory: directory(value) ?? "" },
}, },
data, data,
}) })
@@ -229,6 +229,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
], ],
}) })
return { return {
admittedSeq: 0,
id: value.id ?? "", id: value.id ?? "",
sessionID: value.sessionID, sessionID: value.sessionID,
timeCreated: Date.now(), timeCreated: Date.now(),
@@ -254,6 +255,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
})), })),
}) })
return { return {
admittedSeq: 0,
id: value.id ?? "", id: value.id ?? "",
sessionID: value.sessionID, sessionID: value.sessionID,
timeCreated: Date.now(), timeCreated: Date.now(),
@@ -278,6 +280,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
modelID: value.model.modelID, modelID: value.model.modelID,
}) })
return { return {
admittedSeq: 0,
id: value.id ?? "", id: value.id ?? "",
sessionID: value.sessionID, sessionID: value.sessionID,
timeCreated: Date.now(), timeCreated: Date.now(),
@@ -298,19 +301,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: { project: {
...input.current.project, ...input.current.project,
async list() { async list() {
return ((await legacy().project.list()).data ?? []).map((project) => ({ return ((await legacy().project.list()).data ?? []) as Project[]
...project,
canonical: project.worktree,
}))
}, },
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) { async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
const result = await legacy(value?.location).project.current() const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found") if (!result.data) throw new Error("Project not found")
return { return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
id: result.data.id,
directory: result.data.worktree,
canonical: result.data.worktree,
} satisfies ProjectCurrent
}, },
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) { // async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
+7
View File
@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session { export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input if (!("location" in input)) return input
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID, parentID: input.parentID,
cost: input.cost, cost: input.cost,
tokens: input.tokens, tokens: input.tokens,
title: withTimestampedFallback(input), title: input.title,
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
version: "", version: "",
-3
View File
@@ -40,9 +40,6 @@
"open": "10.1.2", "open": "10.1.2",
"semver": "catalog:", "semver": "catalog:",
"solid-js": "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", "uqr": "0.1.3",
"ws": "8.21.0" "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 }) await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets) const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target } const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input)) await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch const host = target.platform === process.platform && target.arch === process.arch
if (host) { if (host) {
+3 -8
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets" import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target" import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
@@ -43,10 +43,6 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset, key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)), source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
}, },
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({ ...attentionSoundAssets.map((key) => ({
key, key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)), source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
@@ -58,9 +54,8 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative), source: path.join(ptyRoot, relative),
})), })),
] ]
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()] await Promise.all(assets.map((asset) => stat(asset.source)))
await Promise.all(unique.map((asset) => stat(asset.source))) return assets
return unique
} }
export async function hashNodeAssets(assets: readonly NodeAsset[]) { export async function hashNodeAssets(assets: readonly NodeAsset[]) {
+2 -6
View File
@@ -8,7 +8,6 @@ import {
type SessionMessageInfo, type SessionMessageInfo,
type SkillInfo, type SkillInfo,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type { import type {
AgentSideConnection, AgentSideConnection,
AuthenticateRequest, AuthenticateRequest,
@@ -214,7 +213,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({ sessions: page.data.map((session) => ({
sessionId: session.id, sessionId: session.id,
cwd: session.location.directory, cwd: session.location.directory,
title: withTimestampedFallback(session), title: session.title,
updatedAt: new Date(session.time.updated).toISOString(), updatedAt: new Date(session.time.updated).toISOString(),
})), })),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
@@ -245,10 +244,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {} return {}
}, },
forkSession: async (params) => { forkSession: async (params) => {
const forked = await input.client.session.fork({ const forked = await input.client.session.fork({ sessionID: params.sessionId })
sessionID: params.sessionId,
boundary: { type: "through" },
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? []) const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state) await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) } return { sessionId: state.id, configOptions: configOptions(state) }

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