mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaff6ffe2 | |||
| 99cae9766b |
@@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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")({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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")({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }) }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")],
|
||||||
|
|||||||
@@ -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 })]))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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 } },
|
|
||||||
})
|
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 },
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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: "",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,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[]) {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user