mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eec3e640a | |||
| 4dc5ba66d8 | |||
| a21598901d | |||
| 3a7f507135 | |||
| b1f86ee72b | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f | |||
| 85ea15e56d | |||
| 6a543791b6 | |||
| 47c6840752 | |||
| 14e4c3dfd7 | |||
| ee6577936d | |||
| 8285a18f18 | |||
| cfcd8de62a | |||
| c2db18c59b | |||
| 70393a1d18 | |||
| eab59ea021 | |||
| 4569659b03 | |||
| b5afaed053 | |||
| 97f5fb3399 | |||
| 18843682ad | |||
| 9eb9c1db7d | |||
| 8846336e11 | |||
| f6b5f6b6b3 | |||
| 02df1e9737 | |||
| 5b84e39f6d | |||
| 6d3f84b4ff | |||
| 255e9bbf06 | |||
| 25b6a179e2 | |||
| 80874c241f | |||
| f99469bdf8 | |||
| fe0eb4ea8f | |||
| a484f87680 | |||
| 3b6ab392ac | |||
| 6c567dc745 | |||
| 92567700f6 | |||
| 90c84639e3 | |||
| 56c6add5c3 | |||
| 2cfe8883ea | |||
| 102086c50f | |||
| 06e26d89ad | |||
| 84dd56ed34 | |||
| f92e490bb6 | |||
| 0481dba88e | |||
| dd0e41c633 | |||
| b69e51d835 | |||
| e60e8d9387 | |||
| 3bfce3fd2d | |||
| b498a5c6c4 | |||
| db9e942398 | |||
| 0e26116f68 | |||
| 3468aa0140 | |||
| 7757f712a2 | |||
| 7129ed6e62 | |||
| 6c37842520 | |||
| dc3c996892 | |||
| 7fd12c560c | |||
| 7aaf4e7750 | |||
| 1311d909a7 | |||
| d07d9ae0da | |||
| 1d18459cdc | |||
| db45026c6c | |||
| 77df98db51 | |||
| ff2b184af1 | |||
| 671e164f8d | |||
| 5b4ebf3e9d | |||
| 98229d466d | |||
| 5c7e2b5042 | |||
| 0a6a5d3e80 | |||
| eb95bd27fe | |||
| 865f512a44 | |||
| a460f02f67 | |||
| 146fdb9de1 | |||
| b866900417 | |||
| 8e3e94aa26 | |||
| cc1289048e | |||
| 7814568ba0 | |||
| 16b247f756 | |||
| 9abd9594de | |||
| 22d2012f75 | |||
| cc883058df | |||
| 8ebc4c6f85 | |||
| b0ed9990b3 | |||
| e1c04dcce6 | |||
| 188c642d8c | |||
| 9d55b223bb | |||
| ce7a7e4e23 | |||
| cba5ba03e3 | |||
| 49081a4e24 | |||
| d6371f2fcd | |||
| 7625cbdf47 | |||
| 0ffec67ca3 | |||
| bd132f2614 | |||
| dcb0df4ac1 | |||
| 02f3504055 | |||
| f23ee5e4a8 | |||
| 77aa85c589 | |||
| a618946b7e | |||
| f9de608dea | |||
| 906dc8f5b2 | |||
| 12971935ab | |||
| 78c139b8b5 | |||
| c161978852 | |||
| cb37a7166a | |||
| 488445a679 | |||
| 97786afdd8 | |||
| b1a5e8a6ae | |||
| 0ece10af43 |
@@ -67,6 +67,7 @@
|
|||||||
"@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",
|
||||||
@@ -87,7 +88,6 @@
|
|||||||
"@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,7 +143,10 @@
|
|||||||
"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": {
|
||||||
@@ -380,7 +383,6 @@
|
|||||||
"@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:*",
|
||||||
@@ -401,9 +403,11 @@
|
|||||||
"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",
|
||||||
"semver": "^7.6.3",
|
"tree-sitter-bash": "0.25.0",
|
||||||
|
"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:",
|
||||||
},
|
},
|
||||||
@@ -420,7 +424,6 @@
|
|||||||
"@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:",
|
||||||
@@ -514,6 +517,7 @@
|
|||||||
"@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:",
|
||||||
@@ -1073,9 +1077,11 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -4072,10 +4078,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=="],
|
||||||
|
|
||||||
"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=="],
|
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||||
|
|
||||||
|
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||||
|
|
||||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
"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=="],
|
||||||
@@ -5634,6 +5640,10 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -6856,6 +6866,8 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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"])
|
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||||
|
|
||||||
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,6 +133,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,30 @@ 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 & {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +132,12 @@ 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({
|
||||||
@@ -123,7 +150,10 @@ 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),
|
||||||
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resolveOptions = (request: LLMRequest) => {
|
const resolveOptions = (request: LLMRequest) => {
|
||||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
const input = request.providerOptions?.gemini
|
||||||
if (!ProviderShared.isRecord(value)) return {}
|
const value = input?.thinkingConfig
|
||||||
const thinkingConfig = {
|
const thinkingConfig = {
|
||||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
thinkingBudget:
|
||||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : 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
|
||||||
@@ -342,7 +393,10 @@ 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,6 +11,7 @@ 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,
|
||||||
@@ -38,6 +39,11 @@ 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,
|
||||||
@@ -47,6 +53,7 @@ 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>
|
||||||
|
|
||||||
@@ -61,7 +68,11 @@ 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({ type: Schema.Literal("text"), text: Schema.String }),
|
Schema.Struct({
|
||||||
|
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 }),
|
||||||
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const OpenAIChatMessage = Schema.Union([
|
const OpenAIChatMessage = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
Schema.Struct({
|
||||||
|
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)]),
|
||||||
@@ -83,10 +97,16 @@ 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({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
Schema.Struct({
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -107,6 +127,7 @@ 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),
|
||||||
@@ -209,13 +230,20 @@ 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`.
|
||||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
interface LoweringOptions {
|
||||||
|
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"]>) =>
|
||||||
@@ -257,11 +285,14 @@ 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* (message: OpenAIChatRequestMessage) {
|
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||||
|
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 })
|
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "media") {
|
if (part.type === "media") {
|
||||||
@@ -270,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
|||||||
}
|
}
|
||||||
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"))
|
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
return {
|
||||||
|
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[] = []
|
||||||
@@ -315,29 +350,44 @@ 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* (message: OpenAIChatRequestMessage) {
|
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||||
|
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({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
messages.push({
|
||||||
|
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({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
messages.push({
|
||||||
|
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) =>
|
||||||
@@ -351,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
|||||||
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)]
|
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||||
return (yield* lowerToolMessages(message)).messages
|
return (yield* lowerToolMessages(message, options)).messages
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||||
const system: OpenAIChatMessage[] =
|
const system: OpenAIChatMessage[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0
|
||||||
|
? []
|
||||||
|
: 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 = () => {
|
||||||
@@ -370,28 +434,53 @@ 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({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
messages.push({
|
||||||
|
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] = { role: "user", content: `${previous.content}\n${part.text}` }
|
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||||
|
? {
|
||||||
|
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: [...previous.content, { type: "text", text: part.text }],
|
content: [
|
||||||
|
...previous.content,
|
||||||
|
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
else messages.push({ role: "user", content: part.text })
|
else
|
||||||
|
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)
|
const lowered = yield* lowerToolMessages(message, options)
|
||||||
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)))
|
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||||
}
|
}
|
||||||
flushImages()
|
flushImages()
|
||||||
return messages
|
return messages
|
||||||
@@ -405,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||||
|
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
|
||||||
@@ -415,19 +507,26 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
|
|||||||
)
|
)
|
||||||
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),
|
messages: yield* lowerMessages(request, options),
|
||||||
tools:
|
tools:
|
||||||
request.tools.length === 0
|
request.tools.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) =>
|
: request.tools.map((tool) =>
|
||||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
lowerTool(
|
||||||
|
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 },
|
||||||
max_tokens: generation?.maxTokens,
|
...(maxTokensField === "max_completion_tokens"
|
||||||
|
? { 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,7 +16,6 @@ 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,
|
||||||
@@ -33,7 +32,6 @@ 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,
|
||||||
@@ -44,11 +42,15 @@ 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)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
(patterns.some((pattern) => pattern.test(message)) || /^400\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
|
||||||
@@ -100,6 +102,8 @@ 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)
|
||||||
@@ -142,12 +146,7 @@ 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 (
|
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||||
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,10 +1,12 @@
|
|||||||
import type { Model, ProviderOptions } from "./schema"
|
import type { Model, ProviderOptions } 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,72 @@ 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 ModelID, type ProviderOptions } from "../schema"
|
import { ProviderID, type CacheHint, 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 usage?: boolean | Record<string, unknown>
|
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||||
readonly reasoning?: Record<string, unknown>
|
readonly models?: ReadonlyArray<string>
|
||||||
|
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 & {
|
||||||
@@ -30,6 +82,12 @@ 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),
|
||||||
])
|
])
|
||||||
@@ -40,7 +98,7 @@ export const protocol = Protocol.make({
|
|||||||
body: {
|
body: {
|
||||||
schema: OpenRouterBody,
|
schema: OpenRouterBody,
|
||||||
from: (request) =>
|
from: (request) =>
|
||||||
OpenAIChat.protocol.body.from(request).pipe(
|
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).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
|
||||||
@@ -71,16 +129,39 @@ 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 {
|
||||||
...(openrouter.usage === true
|
...options,
|
||||||
|
...(usage === undefined || usage === true
|
||||||
? { usage: { include: true } }
|
? { usage: { include: true } }
|
||||||
: isRecord(openrouter.usage)
|
: usage === false
|
||||||
? { usage: openrouter.usage }
|
? { usage: { include: false } }
|
||||||
: {}),
|
: isRecord(usage)
|
||||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
? { usage }
|
||||||
...(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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,4 +194,15 @@ export const configure = (input: ModelOptions = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model = provider.model
|
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) =>
|
||||||
|
configure({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers,
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
|||||||
@@ -1,31 +1,62 @@
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||||
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
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 { OpenAIProviderOptionsInput } from "./openai-options"
|
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 & {
|
||||||
|
readonly xai?: OpenAIOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
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"
|
||||||
|
|
||||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
const responsesRoute = Route.make({
|
||||||
|
id: "openai-responses",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "xai",
|
||||||
|
protocol: OpenAIResponses.protocol,
|
||||||
|
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
|
transport: OpenAIResponses.httpTransport,
|
||||||
|
defaults: { providerOptions: { xai: { store: false } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatRoute = Route.make({
|
||||||
|
id: "openai-compatible-chat",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "xai",
|
||||||
|
protocol: OpenAIChat.protocol,
|
||||||
|
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
|
transport: OpenAICompatibleChat.route.transport,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [responsesRoute, chatRoute]
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
const 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 OpenAIResponses.route.with({
|
return responsesRoute.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),
|
||||||
})
|
})
|
||||||
@@ -33,9 +64,8 @@ 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 OpenAICompatibleChat.route.with({
|
return chatRoute.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),
|
||||||
})
|
})
|
||||||
@@ -44,8 +74,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<OpenAIProviderOptionsInput>({ id: modelID })
|
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||||
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||||
const image = (modelID: string | ModelID) =>
|
const image = (modelID: string | ModelID) =>
|
||||||
XAIImages.model({
|
XAIImages.model({
|
||||||
id: modelID,
|
id: modelID,
|
||||||
@@ -65,7 +95,15 @@ export const configure = (input: ModelOptions = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model = provider.model
|
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
|
configure({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers,
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, 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"
|
||||||
@@ -46,7 +46,11 @@ export interface Route<Body, Prepared = unknown> {
|
|||||||
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: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
|
||||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
readonly prepareTransport: (
|
||||||
|
body: Body,
|
||||||
|
request: LLMRequest,
|
||||||
|
options?: StreamOptions,
|
||||||
|
) => Effect.Effect<Prepared, LLMError>
|
||||||
readonly streamPrepared: (
|
readonly streamPrepared: (
|
||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
@@ -92,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
|||||||
|
|
||||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
||||||
|
|
||||||
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
|
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||||
|
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))
|
||||||
@@ -145,12 +152,16 @@ export interface Interface {
|
|||||||
readonly generate: GenerateMethod
|
readonly generate: GenerateMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
readonly http?: HttpMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateMethod {
|
export interface GenerateMethod {
|
||||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||||
@@ -286,7 +297,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
},
|
},
|
||||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
||||||
makeRouteModel<Options>(route, input),
|
makeRouteModel<Options>(route, input),
|
||||||
prepareTransport: (body, request) =>
|
prepareTransport: (body, request, options) =>
|
||||||
routeInput.transport.prepare({
|
routeInput.transport.prepare({
|
||||||
body,
|
body,
|
||||||
request,
|
request,
|
||||||
@@ -294,6 +305,7 @@ 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}`
|
||||||
@@ -359,14 +371,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||||
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)
|
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request: resolved,
|
request: resolved,
|
||||||
@@ -389,17 +401,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request)
|
const compiled = yield* compile(request, options)
|
||||||
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) {
|
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
const state = yield* stream(request, options).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(
|
||||||
@@ -408,24 +420,24 @@ const generateWith = (stream: Interface["stream"]) =>
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (yield* Service).stream(request)
|
return (yield* Service).stream(request, options)
|
||||||
}),
|
}),
|
||||||
) as Stream.Stream<LLMEvent, LLMError>
|
) as Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
return yield* (yield* Service).generate(request)
|
return yield* (yield* Service).generate(request, options)
|
||||||
}) as Effect.Effect<LLMResponse, LLMError>
|
}) as Effect.Effect<LLMResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const streamRequest = (request: LLMRequest) =>
|
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (yield* Service).stream(request)
|
return (yield* Service).stream(request, options)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Stream } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -20,9 +20,13 @@ 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
|
||||||
@@ -282,12 +286,41 @@ 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) =>
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
return yield* http
|
if (!middleware)
|
||||||
.execute(request)
|
return yield* http
|
||||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
.execute(request)
|
||||||
|
.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,6 +8,7 @@ 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"
|
||||||
@@ -22,4 +23,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 { Transport as TransportDef, TransportRuntime } from "./transport"
|
export type { HttpHandler, HttpMiddleware, 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 { Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, 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,6 +19,7 @@ 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) => {
|
||||||
@@ -28,57 +29,9 @@ 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) }
|
||||||
@@ -120,18 +73,23 @@ 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) =>
|
||||||
jsonRequestParts({
|
Effect.gen(function* () {
|
||||||
...prepareInput,
|
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||||
}).pipe(
|
const request = ProviderShared.jsonPost({
|
||||||
Effect.map((parts) => ({
|
url: parts.url,
|
||||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
body: parts.bodyText,
|
||||||
|
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)
|
.execute(prepared.request, prepared.middleware)
|
||||||
.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 { Interface as RequestExecutorInterface } from "../executor"
|
import type { HttpMiddleware, 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,7 +27,9 @@ 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.Literal("context-overflow")
|
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||||
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")({
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ 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" }),
|
||||||
@@ -168,6 +169,7 @@ 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,6 +123,7 @@ 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),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -166,9 +167,13 @@ 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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Ref, 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"
|
||||||
@@ -137,26 +137,149 @@ describe("request option precedence", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
it.effect("transforms the final HTTP request after serialization and authentication", () =>
|
||||||
Effect.gen(function* () {
|
LLMClient.generate(
|
||||||
const model = OpenAIChat.route
|
LLM.request({
|
||||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
model: OpenAIChat.route
|
||||||
.model({ id: "gpt-4o-mini" })
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
|
||||||
const error = yield* compileRequest(
|
.model({ id: "gpt-4o-mini" }),
|
||||||
LLM.request({
|
prompt: "Say hello.",
|
||||||
model,
|
}),
|
||||||
prompt: "Say hello.",
|
{
|
||||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
http: (request, handler) =>
|
||||||
}),
|
Effect.gen(function* () {
|
||||||
).pipe(Effect.flip)
|
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" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({
|
it.effect("transforms the HTTP response before protocol decoding", () =>
|
||||||
_tag: "InvalidRequest",
|
Effect.gen(function* () {
|
||||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
const response = yield* 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: (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")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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({
|
||||||
|
|||||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||||
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({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({
|
||||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
_tag: "InvalidRequest",
|
||||||
|
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,9 +53,7 @@ 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")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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.",
|
||||||
@@ -19,6 +18,24 @@ 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.",
|
||||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
expect(
|
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
"ProviderInternal",
|
||||||
).toEqual(["ProviderInternal", "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", () => {
|
||||||
|
|||||||
@@ -9,9 +9,39 @@ LLM.request({
|
|||||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
|
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({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
// @ts-expect-error Gemini thinking budgets must be numeric.
|
// @ts-expect-error Gemini thinking budgets must be numeric.
|
||||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Hello",
|
||||||
|
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ 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: { 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({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
|
|||||||
|
|
||||||
const model = XAI.provider.model("grok-4")
|
const model = XAI.provider.model("grok-4")
|
||||||
|
|
||||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
|
||||||
|
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
||||||
providerOptions: { openai: { reasoningEffort: true } },
|
providerOptions: { xai: { reasoningEffort: true } },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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()
|
||||||
@@ -29,6 +31,35 @@ 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",
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ describe("Gemini route", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
providerOptions: {
|
||||||
|
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* compileRequest(
|
||||||
@@ -49,12 +56,33 @@ describe("Gemini route", () => {
|
|||||||
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([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -181,23 +181,6 @@ describe("Google Vertex providers", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const error = yield* compileRequest(
|
|
||||||
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(
|
||||||
|
|||||||
@@ -144,6 +144,20 @@ 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* compileRequest(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM, Message } from "../../src"
|
import { CacheHint, LLM, Message } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import { compileRequest } from "../../src/route/client"
|
import { compileRequest } from "../../src/route/client"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
|
|||||||
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* compileRequest(
|
||||||
@@ -42,6 +163,13 @@ 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"),
|
||||||
@@ -53,10 +181,54 @@ 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")
|
||||||
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
|
|||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
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([
|
||||||
{
|
{
|
||||||
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
|
|||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
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",
|
||||||
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
|
|||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
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",
|
||||||
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
|
|||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
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" })],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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"
|
||||||
@@ -54,7 +55,10 @@ 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(() => props.session()?.title ?? props.fallbackTitle)
|
const title = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
return session ? displayLabel(session) : props.fallbackTitle
|
||||||
|
})
|
||||||
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const session = props.session()
|
const session = props.session()
|
||||||
@@ -143,7 +147,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(() => {
|
||||||
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
projectName: projectName(),
|
projectName: projectName(),
|
||||||
title: props.session()?.title,
|
title: 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)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
||||||
.map(normalizeProjectInfo)
|
.map(normalizeProjectInfo)
|
||||||
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ 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,6 +3,7 @@ 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"
|
||||||
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
|||||||
if (!value) return []
|
if (!value) return []
|
||||||
return sessions.data
|
return sessions.data
|
||||||
.searchRecords()
|
.searchRecords()
|
||||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
.filter((record) => `${displayLabel(record.session)} ${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(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => displayLabel(props.record.session))
|
||||||
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(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => displayLabel(props.record.session))
|
||||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
@@ -14,7 +15,6 @@ 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 = () => sessionTitle(props.session.title)
|
const title = () => displayLabel(props.session)
|
||||||
|
|
||||||
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={sessionTitle(props.session.title)}
|
value={displayLabel(props.session)}
|
||||||
gutter={10}
|
gutter={10}
|
||||||
class="min-w-0 w-full"
|
class="min-w-0 w-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
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 { createTimelineProjection } from "./projection"
|
||||||
@@ -296,8 +296,11 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync().session.get(id)
|
return sync().session.get(id)
|
||||||
})
|
})
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleLabel = createMemo(() => {
|
||||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
const session = info()
|
||||||
|
if (!session) return
|
||||||
|
return displayLabel(session)
|
||||||
|
})
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
@@ -311,7 +314,10 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return emptyMessages
|
if (!id) return emptyMessages
|
||||||
return sync().data.message[id] ?? emptyMessages
|
return sync().data.message[id] ?? emptyMessages
|
||||||
})
|
})
|
||||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
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 getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||||
const childTaskDescription = createMemo(() => {
|
const childTaskDescription = createMemo(() => {
|
||||||
@@ -329,7 +335,7 @@ export function MessageTimeline(props: {
|
|||||||
if (value) return value
|
if (value) return value
|
||||||
return language.t("command.session.new")
|
return language.t("command.session.new")
|
||||||
})
|
})
|
||||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
|
||||||
const projection = createTimelineProjection({
|
const projection = createTimelineProjection({
|
||||||
messages: sessionMessages,
|
messages: sessionMessages,
|
||||||
userMessages: () => props.userMessages,
|
userMessages: () => props.userMessages,
|
||||||
@@ -912,9 +918,10 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
const name = createMemo(
|
const name = createMemo(() => {
|
||||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
const session = sync().session.get(props.sessionID)
|
||||||
)
|
return session ? displayLabel(session) : language.t("command.session.new")
|
||||||
|
})
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await deleteSession(props.sessionID)
|
await deleteSession(props.sessionID)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -38,17 +38,19 @@ 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))
|
||||||
|
|||||||
@@ -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) ?? "" },
|
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
project: {
|
project: {
|
||||||
...input.current.project,
|
...input.current.project,
|
||||||
async list() {
|
async list() {
|
||||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
return ((await legacy().project.list()).data ?? []).map((project) => ({
|
||||||
|
...project,
|
||||||
|
canonical: project.worktree,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
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 { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
return {
|
||||||
|
id: result.data.id,
|
||||||
|
directory: result.data.worktree,
|
||||||
|
canonical: result.data.worktree,
|
||||||
|
} satisfies ProjectCurrent
|
||||||
},
|
},
|
||||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
// 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)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -13,7 +14,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: input.title,
|
title: withTimestampedFallback(input),
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
version: "",
|
version: "",
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
"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 build(mainConfig(input))
|
|
||||||
await copyNodeAssets(assets)
|
await copyNodeAssets(assets)
|
||||||
|
await build(mainConfig(input))
|
||||||
|
|
||||||
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 } from "../src/node/target"
|
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
|
||||||
@@ -43,6 +43,10 @@ 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)),
|
||||||
@@ -54,8 +58,9 @@ export async function collectNodeAssets(target: NodeTarget) {
|
|||||||
source: path.join(ptyRoot, relative),
|
source: path.join(ptyRoot, relative),
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
await Promise.all(assets.map((asset) => stat(asset.source)))
|
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
|
||||||
return assets
|
await Promise.all(unique.map((asset) => stat(asset.source)))
|
||||||
|
return unique
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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,
|
||||||
@@ -213,7 +214,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: session.title,
|
title: withTimestampedFallback(session),
|
||||||
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 } : {}),
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "Manage plugins",
|
description: "Manage plugins",
|
||||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||||
}),
|
}),
|
||||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
|
||||||
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
|||||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
...(kv.dismissed_getting_started === undefined
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
hints: {
|
|
||||||
onboarding: !kv.dismissed_getting_started,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
plugin: {
|
plugin: {
|
||||||
list: () => import("./commands/handlers/plugin/list"),
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
},
|
},
|
||||||
migrate: () => import("./commands/handlers/migrate"),
|
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||||
|
export const shellParserWasmAssets = {
|
||||||
|
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||||
|
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||||
|
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||||
|
} as const
|
||||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||||
|
|
||||||
export const attentionSoundAssets = [
|
export const attentionSoundAssets = [
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
|||||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function warning(message: string) {
|
|
||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
if (error instanceof Error) return error.message
|
if (error instanceof Error) return error.message
|
||||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
|
|||||||
terminal: { title: false },
|
terminal: { title: false },
|
||||||
prompt: { editor: false, paste: "full" },
|
prompt: { editor: false, paste: "full" },
|
||||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||||
hints: { onboarding: false },
|
|
||||||
animations: false,
|
animations: false,
|
||||||
mouse: false,
|
mouse: false,
|
||||||
})
|
})
|
||||||
expect(config).not.toHaveProperty("skipped_version")
|
expect(config).not.toHaveProperty("skipped_version")
|
||||||
expect(config).not.toHaveProperty("which_key")
|
expect(config).not.toHaveProperty("which_key")
|
||||||
|
expect(config).not.toHaveProperty("hints")
|
||||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { collectNodeAssets } from "../script/node-assets"
|
||||||
|
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||||
|
|
||||||
|
test("collects each SEA asset key once", async () => {
|
||||||
|
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||||
|
const keys = assets.map((asset) => asset.key)
|
||||||
|
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||||
|
{
|
||||||
|
key: shellParserWasmAssets.runtime,
|
||||||
|
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
|
|||||||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||||
|
|
||||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||||
return { directory, workspaceID, project: { id: "project", directory } }
|
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { createRequire } from "node:module"
|
import { createRequire } from "node:module"
|
||||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
import {
|
||||||
|
nodeExecArgv,
|
||||||
|
nodeTarget,
|
||||||
|
type NodeTarget,
|
||||||
|
photonWasmAsset,
|
||||||
|
shellParserWasmAssets,
|
||||||
|
} from "./src/node/target"
|
||||||
|
|
||||||
const dir = import.meta.dirname
|
const dir = import.meta.dirname
|
||||||
|
|
||||||
@@ -194,6 +200,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
|||||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||||
|
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||||
|
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||||
|
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
|
|||||||
|
|
||||||
export type Endpoint5_1Input = {
|
export type Endpoint5_1Input = {
|
||||||
readonly id?: Session.ID | undefined
|
readonly id?: Session.ID | undefined
|
||||||
|
readonly title?: string | undefined
|
||||||
readonly agent?: Agent.ID | undefined
|
readonly agent?: Agent.ID | undefined
|
||||||
readonly model?: Model.Ref | undefined
|
readonly model?: Model.Ref | undefined
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
@@ -397,7 +398,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -523,7 +524,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||||
readonly tokens?:
|
readonly tokens?:
|
||||||
| {
|
| {
|
||||||
@@ -685,7 +686,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly content?:
|
readonly content?:
|
||||||
| readonly [
|
| readonly [
|
||||||
(
|
(
|
||||||
@@ -725,7 +726,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly attempt: number
|
readonly attempt: number
|
||||||
readonly at: number
|
readonly at: number
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -775,7 +776,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly reason: "auto" | "manual"
|
readonly reason: "auto" | "manual"
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly inputID?: SessionMessage.ID | undefined
|
readonly inputID?: SessionMessage.ID | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,13 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
|||||||
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
|
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
|
||||||
preserveEffect<Endpoint5_1Output>()(
|
preserveEffect<Endpoint5_1Output>()(
|
||||||
raw["session.create"]({
|
raw["session.create"]({
|
||||||
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
|
payload: {
|
||||||
|
id: input?.["id"],
|
||||||
|
title: input?.["title"],
|
||||||
|
agent: input?.["agent"],
|
||||||
|
model: input?.["model"],
|
||||||
|
location: input?.["location"],
|
||||||
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ export function make(options: ClientOptions) {
|
|||||||
path: `/api/session`,
|
path: `/api/session`,
|
||||||
body: {
|
body: {
|
||||||
id: input?.["id"],
|
id: input?.["id"],
|
||||||
|
title: input?.["title"],
|
||||||
agent: input?.["agent"],
|
agent: input?.["agent"],
|
||||||
model: input?.["model"],
|
model: input?.["model"],
|
||||||
location: input?.["location"],
|
location: input?.["location"],
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
|||||||
|
|
||||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||||
|
|
||||||
export type SessionStructuredError = { type: string; message: string }
|
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||||
|
|
||||||
export type SessionMessageCompactionRunning = {
|
export type SessionMessageCompactionRunning = {
|
||||||
type: "compaction"
|
type: "compaction"
|
||||||
@@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
|
|||||||
|
|
||||||
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
||||||
|
|
||||||
export type ProjectCurrent = { id: string; directory: string }
|
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||||
|
|
||||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||||
|
|
||||||
@@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
|
|||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
worktree: string
|
canonical: string
|
||||||
vcs?: ProjectVcs
|
vcs?: ProjectVcs
|
||||||
name?: string
|
name?: string
|
||||||
icon?: ProjectIcon
|
icon?: ProjectIcon
|
||||||
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
|
|||||||
cost: MoneyUSD
|
cost: MoneyUSD
|
||||||
tokens: TokenUsageInfo
|
tokens: TokenUsageInfo
|
||||||
time: { created: number; updated: number; archived?: number }
|
time: { created: number; updated: number; archived?: number }
|
||||||
title: string
|
title?: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
subpath?: string
|
subpath?: string
|
||||||
revert?: SessionRevert
|
revert?: SessionRevert
|
||||||
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
|
|||||||
cost?: number
|
cost?: number
|
||||||
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||||
share?: { url: string }
|
share?: { url: string }
|
||||||
title: string
|
title?: string
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
version: string
|
version: string
|
||||||
@@ -2515,7 +2515,11 @@ export type LocationGetInput = {
|
|||||||
}["location"]
|
}["location"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
export type LocationGetOutput = {
|
||||||
|
directory: string
|
||||||
|
workspaceID?: string
|
||||||
|
project: { id: string; directory: string; canonical: string }
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentListInput = {
|
export type AgentListInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
@@ -2524,7 +2528,7 @@ export type AgentListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentListOutput = {
|
export type AgentListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<AgentInfo>
|
data: Array<AgentInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2536,7 +2540,7 @@ export type AgentGetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentGetOutput = {
|
export type AgentGetOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: AgentInfo
|
data: AgentInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2547,7 +2551,7 @@ export type PluginListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PluginListOutput = {
|
export type PluginListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<PluginInfo>
|
data: Array<PluginInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2658,24 +2662,35 @@ export type SessionListOutput = SessionsResponse
|
|||||||
export type SessionCreateInput = {
|
export type SessionCreateInput = {
|
||||||
readonly id?: {
|
readonly id?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
|
readonly title?: string | null
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||||
}["id"]
|
}["id"]
|
||||||
|
readonly title?: {
|
||||||
|
readonly id?: string | null
|
||||||
|
readonly title?: string | null
|
||||||
|
readonly agent?: string | null
|
||||||
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||||
|
}["title"]
|
||||||
readonly agent?: {
|
readonly agent?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
|
readonly title?: string | null
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||||
}["agent"]
|
}["agent"]
|
||||||
readonly model?: {
|
readonly model?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
|
readonly title?: string | null
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||||
}["model"]
|
}["model"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
|
readonly title?: string | null
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||||
@@ -3231,7 +3246,7 @@ export type ModelListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ModelListOutput = {
|
export type ModelListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<ModelInfo>
|
data: Array<ModelInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3242,7 +3257,7 @@ export type ModelDefaultInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ModelDefaultOutput = {
|
export type ModelDefaultOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: ModelInfo | null
|
data: ModelInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3269,7 +3284,7 @@ export type ProviderListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderListOutput = {
|
export type ProviderListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<ProviderInfo>
|
data: Array<ProviderInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3281,7 +3296,7 @@ export type ProviderGetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderGetOutput = {
|
export type ProviderGetOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: ProviderInfo
|
data: ProviderInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3292,7 +3307,7 @@ export type IntegrationListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationListOutput = {
|
export type IntegrationListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<IntegrationInfo>
|
data: Array<IntegrationInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3304,7 +3319,7 @@ export type IntegrationGetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationGetOutput = {
|
export type IntegrationGetOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: IntegrationInfo | null
|
data: IntegrationInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3351,7 +3366,7 @@ export type IntegrationOauthConnectInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationOauthConnectOutput = {
|
export type IntegrationOauthConnectOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: {
|
data: {
|
||||||
attemptID: string
|
attemptID: string
|
||||||
url: string
|
url: string
|
||||||
@@ -3370,7 +3385,7 @@ export type IntegrationOauthStatusInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationOauthStatusOutput = {
|
export type IntegrationOauthStatusOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: IntegrationAttemptStatus
|
data: IntegrationAttemptStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3405,7 +3420,7 @@ export type IntegrationCommandConnectInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationCommandConnectOutput = {
|
export type IntegrationCommandConnectOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: IntegrationCommandAttempt
|
data: IntegrationCommandAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3418,7 +3433,7 @@ export type IntegrationCommandStatusInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationCommandStatusOutput = {
|
export type IntegrationCommandStatusOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: IntegrationCommandAttemptStatus
|
data: IntegrationCommandAttemptStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3439,7 +3454,7 @@ export type McpListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type McpListOutput = {
|
export type McpListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<McpServer>
|
data: Array<McpServer>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3528,7 +3543,7 @@ export type McpResourceCatalogInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type McpResourceCatalogOutput = {
|
export type McpResourceCatalogOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: McpResourceCatalog
|
data: McpResourceCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3577,7 +3592,7 @@ export type FormRequestListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FormRequestListOutput = {
|
export type FormRequestListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<FormInfo>
|
data: Array<FormInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4433,7 +4448,7 @@ export type PermissionRequestListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionRequestListOutput = {
|
export type PermissionRequestListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<PermissionRequest>
|
data: Array<PermissionRequest>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4555,7 +4570,7 @@ export type FileListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FileListOutput = {
|
export type FileListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<FileSystemEntry>
|
data: Array<FileSystemEntry>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4587,7 +4602,7 @@ export type FileFindInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FileFindOutput = {
|
export type FileFindOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<FileSystemEntry>
|
data: Array<FileSystemEntry>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4598,7 +4613,7 @@ export type CommandListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CommandListOutput = {
|
export type CommandListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<CommandInfo>
|
data: Array<CommandInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4609,7 +4624,7 @@ export type SkillListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SkillListOutput = {
|
export type SkillListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<SkillInfo>
|
data: Array<SkillInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4622,7 +4637,7 @@ export type PtyListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PtyListOutput = {
|
export type PtyListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<Pty>
|
data: Array<Pty>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4668,7 +4683,7 @@ export type PtyCreateInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PtyCreateOutput = {
|
export type PtyCreateOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Pty
|
data: Pty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4680,7 +4695,7 @@ export type PtyGetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PtyGetOutput = {
|
export type PtyGetOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Pty
|
data: Pty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4697,7 +4712,7 @@ export type PtyUpdateInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PtyUpdateOutput = {
|
export type PtyUpdateOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Pty
|
data: Pty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4717,7 +4732,7 @@ export type ShellListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellListOutput = {
|
export type ShellListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<ShellInfo1>
|
data: Array<ShellInfo1>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4752,7 +4767,7 @@ export type ShellCreateInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellCreateOutput = {
|
export type ShellCreateOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: ShellInfo1
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4764,7 +4779,7 @@ export type ShellGetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellGetOutput = {
|
export type ShellGetOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: ShellInfo1
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4777,7 +4792,7 @@ export type ShellTimeoutInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellTimeoutOutput = {
|
export type ShellTimeoutOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: ShellInfo1
|
data: ShellInfo1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4801,7 +4816,7 @@ export type ShellOutputInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ShellOutputOutput = {
|
export type ShellOutputOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: { output: string; cursor: number; size: number; truncated: boolean }
|
data: { output: string; cursor: number; size: number; truncated: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4821,7 +4836,7 @@ export type QuestionRequestListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionRequestListOutput = {
|
export type QuestionRequestListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<QuestionRequest>
|
data: Array<QuestionRequest>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4851,7 +4866,7 @@ export type ReferenceListInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ReferenceListOutput = {
|
export type ReferenceListOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<ReferenceInfo>
|
data: Array<ReferenceInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4894,7 +4909,7 @@ export type VcsStatusInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VcsStatusOutput = {
|
export type VcsStatusOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<VcsFileStatus>
|
data: Array<VcsFileStatus>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4917,7 +4932,7 @@ export type VcsDiffInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VcsDiffOutput = {
|
export type VcsDiffOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<FileDiffInfo>
|
data: Array<FileDiffInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4938,7 +4953,7 @@ export type WebsearchProvidersInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebsearchProvidersOutput = {
|
export type WebsearchProvidersOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: Array<WebSearchProvider>
|
data: Array<WebSearchProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4951,6 +4966,6 @@ export type WebsearchQueryInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebsearchQueryOutput = {
|
export type WebsearchQueryOutput = {
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||||
data: { providerID: string; results: Array<WebSearchResult> }
|
data: { providerID: string; results: Array<WebSearchResult> }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
"node": "./src/image/photon-wasm.node.ts",
|
"node": "./src/image/photon-wasm.node.ts",
|
||||||
"default": "./src/image/photon-wasm.bun.ts"
|
"default": "./src/image/photon-wasm.bun.ts"
|
||||||
},
|
},
|
||||||
|
"#shell-parser-wasm": {
|
||||||
|
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||||
|
"node": "./src/shell/parser-wasm.node.ts",
|
||||||
|
"default": "./src/shell/parser-wasm.bun.ts"
|
||||||
|
},
|
||||||
"#process-lock-ffi": {
|
"#process-lock-ffi": {
|
||||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||||
"node": "./src/util/process-lock-ffi.node.ts",
|
"node": "./src/util/process-lock-ffi.node.ts",
|
||||||
@@ -51,7 +56,6 @@
|
|||||||
"@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",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
@@ -95,7 +99,6 @@
|
|||||||
"@ff-labs/fff-node": "0.10.1",
|
"@ff-labs/fff-node": "0.10.1",
|
||||||
"@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/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -117,9 +120,11 @@
|
|||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"semver": "^7.6.3",
|
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
|
"tree-sitter-bash": "0.25.0",
|
||||||
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"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:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
|
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||||
"prevIds": [
|
"prevIds": [
|
||||||
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
|
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||||
],
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
@@ -1302,7 +1302,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export * as AISDKNative from "./aisdk-native"
|
||||||
|
|
||||||
|
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||||
|
import { Provider } from "./provider"
|
||||||
|
|
||||||
|
export interface Mapping {
|
||||||
|
readonly package: string
|
||||||
|
readonly settings: Readonly<Record<string, unknown>>
|
||||||
|
readonly headers?: Readonly<Record<string, string>>
|
||||||
|
readonly body?: Readonly<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||||
|
const baseSettings = mapBaseSettings(settings)
|
||||||
|
switch (packageName) {
|
||||||
|
case "@ai-sdk/google":
|
||||||
|
return {
|
||||||
|
package: "@opencode-ai/ai/providers/google",
|
||||||
|
settings: {
|
||||||
|
...baseSettings,
|
||||||
|
...mapAPIKey(settings),
|
||||||
|
...mapGoogleOptions(settings),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "@openrouter/ai-sdk-provider":
|
||||||
|
return mapOpenRouter(settings, baseSettings)
|
||||||
|
case "@ai-sdk/xai":
|
||||||
|
return {
|
||||||
|
package: "@opencode-ai/ai/providers/xai",
|
||||||
|
settings: {
|
||||||
|
...baseSettings,
|
||||||
|
...mapAPIKey(settings),
|
||||||
|
...mapXAIOptions(settings),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||||
|
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||||
|
const input = settings.thinkingConfig
|
||||||
|
const thinkingConfig = {
|
||||||
|
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||||
|
...(isRecord(input) && typeof input.includeThoughts === "boolean"
|
||||||
|
? { includeThoughts: input.includeThoughts }
|
||||||
|
: {}),
|
||||||
|
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||||
|
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||||
|
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||||
|
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||||
|
}
|
||||||
|
if (Object.keys(options).length === 0) return {}
|
||||||
|
return { providerOptions: { gemini: options } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOpenRouter(
|
||||||
|
settings: Readonly<Record<string, unknown>>,
|
||||||
|
baseSettings: Readonly<Record<string, unknown>>,
|
||||||
|
): Mapping {
|
||||||
|
const headers =
|
||||||
|
Provider.mergeHeaders(
|
||||||
|
{
|
||||||
|
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
|
||||||
|
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
|
||||||
|
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
|
||||||
|
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
isStringRecord(settings.headers) ? settings.headers : undefined,
|
||||||
|
) ?? {}
|
||||||
|
return {
|
||||||
|
package: "@opencode-ai/ai/providers/openrouter",
|
||||||
|
settings: {
|
||||||
|
...baseSettings,
|
||||||
|
...mapAPIKey(settings),
|
||||||
|
...mapOpenRouterOptions(settings),
|
||||||
|
},
|
||||||
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||||
|
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||||
|
const options = Object.fromEntries(
|
||||||
|
Object.entries(settings).filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
"apiKey",
|
||||||
|
"api_keys",
|
||||||
|
"appName",
|
||||||
|
"appUrl",
|
||||||
|
"authToken",
|
||||||
|
"baseURL",
|
||||||
|
"chunkTimeout",
|
||||||
|
"compatibility",
|
||||||
|
"extraBody",
|
||||||
|
"fetch",
|
||||||
|
"headers",
|
||||||
|
"timeout",
|
||||||
|
].includes(key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (Object.keys(options).length === 0) return {}
|
||||||
|
return { providerOptions: { openrouter: options } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||||
|
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||||
|
const options = {
|
||||||
|
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||||
|
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||||
|
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||||
|
}
|
||||||
|
if (Object.keys(options).length === 0) return {}
|
||||||
|
return { providerOptions: { xai: options } }
|
||||||
|
}
|
||||||
@@ -299,8 +299,6 @@ export const locationLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = locationLayer
|
|
||||||
|
|
||||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||||
const packageName = Provider.packageName(info.package!)
|
const packageName = Provider.packageName(info.package!)
|
||||||
const projected = mapBodyToProviderOptions(info, packageName)
|
const projected = mapBodyToProviderOptions(info, packageName)
|
||||||
@@ -332,7 +330,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
|||||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||||
headers: info.headers,
|
headers: info.headers,
|
||||||
},
|
},
|
||||||
limits: { context: info.limit.context, output: info.limit.output },
|
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||||
providerOptions,
|
providerOptions,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { ConfigAgent } from "./config/agent"
|
import { ConfigAgent } from "./config/agent"
|
||||||
import { ConfigAttachments } from "./config/attachments"
|
import { ConfigMedia } from "./config/media"
|
||||||
import { ConfigCompaction } from "./config/compaction"
|
import { ConfigCompaction } from "./config/compaction"
|
||||||
import { ConfigCommand } from "./config/command"
|
import { ConfigCommand } from "./config/command"
|
||||||
import { ConfigExperimental } from "./config/experimental"
|
import { ConfigExperimental } from "./config/experimental"
|
||||||
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Enable built-in language servers or configure server overrides",
|
description: "Enable built-in language servers or configure server overrides",
|
||||||
}),
|
}),
|
||||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Attachment processing configuration",
|
description: "Media processing configuration",
|
||||||
}),
|
}),
|
||||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||||
description: "Tool output truncation thresholds",
|
description: "Tool output truncation thresholds",
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
export * as ConfigAttachments from "./attachments"
|
export * as ConfigMedia from "./media"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
|
|
||||||
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
|
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||||
max_width: PositiveInt.pipe(Schema.optional),
|
max_width: PositiveInt.pipe(Schema.optional),
|
||||||
max_height: PositiveInt.pipe(Schema.optional),
|
max_height: PositiveInt.pipe(Schema.optional),
|
||||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||||
image: Image.pipe(Schema.optional),
|
image: Image.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
export * as MoveSession from "./move-session"
|
|
||||||
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Git } from "../git"
|
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { Session } from "../session"
|
|
||||||
import { SessionExecution } from "../session/execution"
|
|
||||||
import { SessionSchema } from "../session/schema"
|
|
||||||
import { SessionStore } from "../session/store"
|
|
||||||
import { AbsolutePath } from "../schema"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const Destination = Schema.Struct({
|
|
||||||
directory: AbsolutePath,
|
|
||||||
}).annotate({ identifier: "MoveSession.Destination" })
|
|
||||||
export type Destination = typeof Destination.Type
|
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
destination: Destination,
|
|
||||||
moveChanges: Schema.optional(Schema.Boolean),
|
|
||||||
}).annotate({ identifier: "MoveSession.Input" })
|
|
||||||
export type Input = typeof Input.Type
|
|
||||||
|
|
||||||
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
|
|
||||||
"MoveSession.DestinationProjectMismatchError",
|
|
||||||
{
|
|
||||||
expected: Project.ID,
|
|
||||||
actual: Project.ID,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
|
||||||
"MoveSession.DestinationNotFoundError",
|
|
||||||
{ directory: AbsolutePath },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
|
||||||
"MoveSession.DestinationNotDirectoryError",
|
|
||||||
{ directory: AbsolutePath },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
|
||||||
"MoveSession.CaptureChangesError",
|
|
||||||
{
|
|
||||||
message: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
|
||||||
"MoveSession.ResetSourceChangesError",
|
|
||||||
{
|
|
||||||
directory: AbsolutePath,
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect()),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export type Error =
|
|
||||||
| Session.NotFoundError
|
|
||||||
| DestinationProjectMismatchError
|
|
||||||
| DestinationNotFoundError
|
|
||||||
| DestinationNotDirectoryError
|
|
||||||
| Session.DestinationNotFoundError
|
|
||||||
| Session.DestinationNotDirectoryError
|
|
||||||
| CaptureChangesError
|
|
||||||
| ApplyChangesError
|
|
||||||
| ResetSourceChangesError
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const git = yield* Git.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const project = yield* Project.Service
|
|
||||||
const sessions = yield* SessionStore.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const execution = yield* SessionExecution.Service
|
|
||||||
|
|
||||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
|
||||||
const current = yield* sessions.get(input.sessionID)
|
|
||||||
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
|
|
||||||
const value = input.destination.directory.trim()
|
|
||||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
|
||||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
|
||||||
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
|
|
||||||
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
|
||||||
if (current.location.directory === directory) return
|
|
||||||
|
|
||||||
const source = yield* project.resolve(current.location.directory)
|
|
||||||
const destination = yield* project.resolve(directory)
|
|
||||||
if (input.moveChanges && current.projectID !== destination.id) {
|
|
||||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
|
||||||
}
|
|
||||||
// A move must not race active execution: a mid-drain relocation would let
|
|
||||||
// the source Location dispatch a request assembled under stale instructions
|
|
||||||
// and history. Serialize like removal does — stop the drain, then move.
|
|
||||||
yield* execution.interrupt(input.sessionID)
|
|
||||||
yield* execution.awaitIdle(input.sessionID)
|
|
||||||
|
|
||||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
|
||||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
|
||||||
if (moveChanges && !sourceRepository)
|
|
||||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
|
||||||
const patch = sourceRepository
|
|
||||||
? yield* git.change
|
|
||||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
|
||||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
|
||||||
: Git.ChangeSet.make("")
|
|
||||||
if (patch) {
|
|
||||||
const repository = yield* git.repo.discover(directory)
|
|
||||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
|
||||||
yield* git.change
|
|
||||||
.apply({ repository, path: directory, changes: patch })
|
|
||||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* session.move({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (patch) {
|
|
||||||
const repository = yield* git.repo.discover(current.location.directory)
|
|
||||||
if (!repository)
|
|
||||||
return yield* new ResetSourceChangesError({
|
|
||||||
directory: current.location.directory,
|
|
||||||
message: "Source is not a Git repository",
|
|
||||||
})
|
|
||||||
yield* git.change
|
|
||||||
.discard({
|
|
||||||
repository,
|
|
||||||
path: current.location.directory,
|
|
||||||
index: "preserve",
|
|
||||||
untracked: "remove",
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ResetSourceChangesError({
|
|
||||||
directory: current.location.directory,
|
|
||||||
message: error.message,
|
|
||||||
cause: error.cause,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ moveSession })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [
|
|
||||||
FSUtil.node,
|
|
||||||
Git.node,
|
|
||||||
Global.node,
|
|
||||||
Project.node,
|
|
||||||
Session.node,
|
|
||||||
SessionStore.node,
|
|
||||||
SessionExecution.node,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
+1
@@ -58,5 +58,6 @@ export const migrations = (
|
|||||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||||
import("./migration/20260722170000_canonical_tool_results"),
|
import("./migration/20260722170000_canonical_tool_results"),
|
||||||
import("./migration/20260729022634_session_fork_boundary"),
|
import("./migration/20260729022634_session_fork_boundary"),
|
||||||
|
import("./migration/20260730195856_optional_session_title"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260730195856_optional_session_title",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
|
||||||
|
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
|
||||||
|
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
|
||||||
|
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -217,7 +217,7 @@ export default {
|
|||||||
\`slug\` text NOT NULL,
|
\`slug\` text NOT NULL,
|
||||||
\`directory\` text NOT NULL,
|
\`directory\` text NOT NULL,
|
||||||
\`path\` text,
|
\`path\` text,
|
||||||
\`title\` text NOT NULL,
|
\`title\` text,
|
||||||
\`version\` text NOT NULL,
|
\`version\` text NOT NULL,
|
||||||
\`share_url\` text,
|
\`share_url\` text,
|
||||||
\`summary_additions\` integer,
|
\`summary_additions\` integer,
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
formatters = builtIns
|
formatters = builtIns
|
||||||
if (configured === true) return
|
if (configured === true) return
|
||||||
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
|
||||||
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, entry] of Object.entries(configured)) {
|
for (const [name, entry] of Object.entries(configured)) {
|
||||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function make(input: {
|
|||||||
readonly fs: FSUtil.Interface
|
readonly fs: FSUtil.Interface
|
||||||
readonly npm: Npm.Interface
|
readonly npm: Npm.Interface
|
||||||
readonly processes: AppProcess.Interface
|
readonly processes: AppProcess.Interface
|
||||||
readonly experimentalOxfmt?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const disabled = false as const
|
const disabled = false as const
|
||||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const layer = Layer.effect(
|
|||||||
locks.withLock(repository.gitDirectory)(effect)
|
locks.withLock(repository.gitDirectory)(effect)
|
||||||
|
|
||||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
|
||||||
Effect.map((matches) => matches[0]),
|
Effect.map((matches) => matches[0]),
|
||||||
Effect.catch(() => Effect.succeed(undefined)),
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
|
||||||
import { z } from "zod/v4"
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export const imageGenerationArgsSchema = z
|
export const imageGenerationArgsSchema = z
|
||||||
@@ -24,91 +23,3 @@ export const imageGenerationArgsSchema = z
|
|||||||
export const imageGenerationOutputSchema = z.object({
|
export const imageGenerationOutputSchema = z.object({
|
||||||
result: z.string(),
|
result: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ImageGenerationArgs = {
|
|
||||||
/**
|
|
||||||
* Background type for the generated image. Default is 'auto'.
|
|
||||||
*/
|
|
||||||
background?: "auto" | "opaque" | "transparent"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input fidelity for the generated image. Default is 'low'.
|
|
||||||
*/
|
|
||||||
inputFidelity?: "low" | "high"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional mask for inpainting.
|
|
||||||
* Contains image_url (string, optional) and file_id (string, optional).
|
|
||||||
*/
|
|
||||||
inputImageMask?: {
|
|
||||||
/**
|
|
||||||
* File ID for the mask image.
|
|
||||||
*/
|
|
||||||
fileId?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64-encoded mask image.
|
|
||||||
*/
|
|
||||||
imageUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The image generation model to use. Default: gpt-image-1.
|
|
||||||
*/
|
|
||||||
model?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moderation level for the generated image. Default: auto.
|
|
||||||
*/
|
|
||||||
moderation?: "auto"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compression level for the output image. Default: 100.
|
|
||||||
*/
|
|
||||||
outputCompression?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The output format of the generated image. One of png, webp, or jpeg.
|
|
||||||
* Default: png
|
|
||||||
*/
|
|
||||||
outputFormat?: "png" | "jpeg" | "webp"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of partial images to generate in streaming mode, from 0 (default value) to 3.
|
|
||||||
*/
|
|
||||||
partialImages?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The quality of the generated image.
|
|
||||||
* One of low, medium, high, or auto. Default: auto.
|
|
||||||
*/
|
|
||||||
quality?: "auto" | "low" | "medium" | "high"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The size of the generated image.
|
|
||||||
* One of 1024x1024, 1024x1536, 1536x1024, or auto.
|
|
||||||
* Default: auto.
|
|
||||||
*/
|
|
||||||
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The generated image encoded in base64.
|
|
||||||
*/
|
|
||||||
result: string
|
|
||||||
},
|
|
||||||
ImageGenerationArgs
|
|
||||||
>({
|
|
||||||
id: "openai.image_generation",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
outputSchema: imageGenerationOutputSchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const imageGeneration = (
|
|
||||||
args: ImageGenerationArgs = {}, // default
|
|
||||||
) => {
|
|
||||||
return imageGenerationToolFactory(args)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
|
||||||
import { z } from "zod/v4"
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export const localShellInputSchema = z.object({
|
export const localShellInputSchema = z.object({
|
||||||
@@ -15,50 +14,3 @@ export const localShellInputSchema = z.object({
|
|||||||
export const localShellOutputSchema = z.object({
|
export const localShellOutputSchema = z.object({
|
||||||
output: z.string(),
|
output: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const localShell = createProviderToolFactoryWithOutputSchema<
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Execute a shell command on the server.
|
|
||||||
*/
|
|
||||||
action: {
|
|
||||||
type: "exec"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The command to run.
|
|
||||||
*/
|
|
||||||
command: string[]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional timeout in milliseconds for the command.
|
|
||||||
*/
|
|
||||||
timeoutMs?: number
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional user to run the command as.
|
|
||||||
*/
|
|
||||||
user?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional working directory to run the command in.
|
|
||||||
*/
|
|
||||||
workingDirectory?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment variables to set for the command.
|
|
||||||
*/
|
|
||||||
env?: Record<string, string>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The output of local shell tool call.
|
|
||||||
*/
|
|
||||||
output: string
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
>({
|
|
||||||
id: "openai.local_shell",
|
|
||||||
inputSchema: localShellInputSchema,
|
|
||||||
outputSchema: localShellOutputSchema,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
|
||||||
import { z } from "zod/v4"
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
// Args validation schema
|
// Args validation schema
|
||||||
@@ -39,65 +38,3 @@ export const webSearchPreviewArgsSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const webSearchPreview = createProviderToolFactory<
|
|
||||||
{
|
|
||||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
|
||||||
},
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Search context size to use for the web search.
|
|
||||||
* - high: Most comprehensive context, highest cost, slower response
|
|
||||||
* - medium: Balanced context, cost, and latency (default)
|
|
||||||
* - low: Least context, lowest cost, fastest response
|
|
||||||
*/
|
|
||||||
searchContextSize?: "low" | "medium" | "high"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User location information to provide geographically relevant search results.
|
|
||||||
*/
|
|
||||||
userLocation?: {
|
|
||||||
/**
|
|
||||||
* Type of location (always 'approximate')
|
|
||||||
*/
|
|
||||||
type: "approximate"
|
|
||||||
/**
|
|
||||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
|
||||||
*/
|
|
||||||
country?: string
|
|
||||||
/**
|
|
||||||
* City name (free text, e.g., 'Minneapolis')
|
|
||||||
*/
|
|
||||||
city?: string
|
|
||||||
/**
|
|
||||||
* Region name (free text, e.g., 'Minnesota')
|
|
||||||
*/
|
|
||||||
region?: string
|
|
||||||
/**
|
|
||||||
* IANA timezone (e.g., 'America/Chicago')
|
|
||||||
*/
|
|
||||||
timezone?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
id: "openai.web_search_preview",
|
|
||||||
inputSchema: z.object({
|
|
||||||
action: z
|
|
||||||
.discriminatedUnion("type", [
|
|
||||||
z.object({
|
|
||||||
type: z.literal("search"),
|
|
||||||
query: z.string().nullish(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("open_page"),
|
|
||||||
url: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("find"),
|
|
||||||
url: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.nullish(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
|
||||||
import { z } from "zod/v4"
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export const webSearchArgsSchema = z.object({
|
export const webSearchArgsSchema = z.object({
|
||||||
@@ -20,83 +19,3 @@ export const webSearchArgsSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const webSearchToolFactory = createProviderToolFactory<
|
|
||||||
{
|
|
||||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
|
||||||
},
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Filters for the search.
|
|
||||||
*/
|
|
||||||
filters?: {
|
|
||||||
/**
|
|
||||||
* Allowed domains for the search.
|
|
||||||
* If not provided, all domains are allowed.
|
|
||||||
* Subdomains of the provided domains are allowed as well.
|
|
||||||
*/
|
|
||||||
allowedDomains?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search context size to use for the web search.
|
|
||||||
* - high: Most comprehensive context, highest cost, slower response
|
|
||||||
* - medium: Balanced context, cost, and latency (default)
|
|
||||||
* - low: Least context, lowest cost, fastest response
|
|
||||||
*/
|
|
||||||
searchContextSize?: "low" | "medium" | "high"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User location information to provide geographically relevant search results.
|
|
||||||
*/
|
|
||||||
userLocation?: {
|
|
||||||
/**
|
|
||||||
* Type of location (always 'approximate')
|
|
||||||
*/
|
|
||||||
type: "approximate"
|
|
||||||
/**
|
|
||||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
|
||||||
*/
|
|
||||||
country?: string
|
|
||||||
/**
|
|
||||||
* City name (free text, e.g., 'Minneapolis')
|
|
||||||
*/
|
|
||||||
city?: string
|
|
||||||
/**
|
|
||||||
* Region name (free text, e.g., 'Minnesota')
|
|
||||||
*/
|
|
||||||
region?: string
|
|
||||||
/**
|
|
||||||
* IANA timezone (e.g., 'America/Chicago')
|
|
||||||
*/
|
|
||||||
timezone?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
id: "openai.web_search",
|
|
||||||
inputSchema: z.object({
|
|
||||||
action: z
|
|
||||||
.discriminatedUnion("type", [
|
|
||||||
z.object({
|
|
||||||
type: z.literal("search"),
|
|
||||||
query: z.string().nullish(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("open_page"),
|
|
||||||
url: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("find"),
|
|
||||||
url: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.nullish(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const webSearch = (
|
|
||||||
args: Parameters<typeof webSearchToolFactory>[0] = {}, // default
|
|
||||||
) => {
|
|
||||||
return webSearchToolFactory(args)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const layer = Layer.effect(
|
|||||||
const image = Object.assign(
|
const image = Object.assign(
|
||||||
{},
|
{},
|
||||||
...(yield* config.entries()).flatMap((entry) =>
|
...(yield* config.entries()).flatMap((entry) =>
|
||||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const normalize = yield* loadAdapter
|
const normalize = yield* loadAdapter
|
||||||
|
|||||||
@@ -148,6 +148,3 @@ export function buildLocationServiceMap(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is temporary for backwards compatibility
|
|
||||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
directory: ref.directory,
|
directory: ref.directory,
|
||||||
workspaceID: ref.workspaceID,
|
workspaceID: ref.workspaceID,
|
||||||
project: { id: resolved.id, directory: resolved.directory },
|
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
|
||||||
vcs: resolved.vcs,
|
vcs: resolved.vcs,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
|||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { AISDK } from "./aisdk"
|
import { AISDK } from "./aisdk"
|
||||||
|
import { AISDKNative } from "./aisdk-native"
|
||||||
import { Catalog } from "./catalog"
|
import { Catalog } from "./catalog"
|
||||||
import { Credential } from "./credential"
|
import { Credential } from "./credential"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
|
||||||
import { Provider } from "./provider"
|
import { Provider } from "./provider"
|
||||||
|
|
||||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||||
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
|
|||||||
headers: providerHeaders(model),
|
headers: providerHeaders(model),
|
||||||
providerOptions: providerOptions(model),
|
providerOptions: providerOptions(model),
|
||||||
http: model.body === undefined ? undefined : { body: model.body },
|
http: model.body === undefined ? undefined : { body: model.body },
|
||||||
limits: { context: model.limit.context, output: model.limit.output },
|
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||||
})
|
})
|
||||||
|
|
||||||
const providerHeaders = (model: Info) => {
|
const providerHeaders = (model: Info) => {
|
||||||
@@ -96,9 +96,7 @@ const providerHeaders = (model: Info) => {
|
|||||||
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = (
|
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||||
model: Info,
|
|
||||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
|
||||||
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||||
if (Object.keys(settings).length === 0) return undefined
|
if (Object.keys(settings).length === 0) return undefined
|
||||||
@@ -152,12 +150,7 @@ export const fromCatalogModel = (
|
|||||||
const packageName = Provider.packageName(resolved.package)
|
const packageName = Provider.packageName(resolved.package)
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
|
|
||||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
|
||||||
return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
@@ -182,7 +175,10 @@ export const fromCatalogModel = (
|
|||||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (Provider.isAISDK(resolved.package)) {
|
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||||
|
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
|
||||||
|
const native = mapping?.package ?? resolved.package
|
||||||
|
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||||
const runtime = produce(resolved, (draft) => {
|
const runtime = produce(resolved, (draft) => {
|
||||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||||
@@ -193,20 +189,20 @@ export const fromCatalogModel = (
|
|||||||
})
|
})
|
||||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||||
}
|
}
|
||||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
if (!native) return Effect.fail(unsupported(resolved))
|
||||||
|
|
||||||
const specifier = resolved.package
|
const specifier = native
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||||
Effect.mapError(() => unsupported(resolved)),
|
Effect.mapError(() => unsupported(resolved)),
|
||||||
)
|
)
|
||||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
const mapped = mapping?.settings ?? configured
|
||||||
const settings = {
|
const settings = {
|
||||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||||
...nativeCredentialSettings(specifier, credential),
|
...nativeCredentialSettings(specifier, credential),
|
||||||
headers: resolved.headers,
|
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||||
body: resolved.body,
|
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||||
}
|
}
|
||||||
return yield* Effect.try({
|
return yield* Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
@@ -223,10 +219,6 @@ export const fromCatalogModel = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
|
||||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
|
||||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
|
||||||
|
|
||||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||||
if (!credential) return {}
|
if (!credential) return {}
|
||||||
if (credential.type === "key") return { apiKey: credential.key }
|
if (credential.type === "key") return { apiKey: credential.key }
|
||||||
@@ -248,22 +240,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
|||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const codexModel = (
|
|
||||||
model: Info,
|
|
||||||
credential: Credential.Value | undefined,
|
|
||||||
key: ReturnType<typeof Auth.value> | undefined,
|
|
||||||
) => {
|
|
||||||
const account = OpenAICodex.accountID(credential)
|
|
||||||
return withDefaults(model, OpenAIResponses.route)
|
|
||||||
.with({
|
|
||||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
|
||||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
|
||||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsupported = (model: Info) =>
|
const unsupported = (model: Info) =>
|
||||||
new UnsupportedPackageError({
|
new UnsupportedPackageError({
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
@@ -288,10 +264,7 @@ export const layer = Layer.effect(
|
|||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (
|
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||||
selected: Info,
|
|
||||||
variant?: VariantID,
|
|
||||||
) {
|
|
||||||
const provider = yield* catalog.provider.get(selected.providerID)
|
const provider = yield* catalog.provider.get(selected.providerID)
|
||||||
const connection = yield* integrations.connection.active(
|
const connection = yield* integrations.connection.active(
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { Permission } from "../permission"
|
|||||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||||
const BUILD_SYSTEM =
|
|
||||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
|
||||||
|
|
||||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
create: (input) =>
|
create: (input) =>
|
||||||
runtime.session.create({
|
runtime.session.create({
|
||||||
id: input?.id,
|
id: input?.id,
|
||||||
|
title: input?.title,
|
||||||
agent: input?.agent,
|
agent: input?.agent,
|
||||||
model: input?.model,
|
model: input?.model,
|
||||||
location:
|
location:
|
||||||
@@ -350,8 +351,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||||
command: runtime.session.command,
|
command: runtime.session.command,
|
||||||
|
rename: runtime.session.rename,
|
||||||
synthetic: runtime.session.synthetic,
|
synthetic: runtime.session.synthetic,
|
||||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||||
|
wait: (input) => runtime.session.wait(input.sessionID),
|
||||||
},
|
},
|
||||||
} satisfies Plugin.Context
|
} satisfies Plugin.Context
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
export * as LayerMapExample from "./layer-map.example"
|
|
||||||
|
|
||||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tutorial: split global services from context-specific services.
|
|
||||||
*
|
|
||||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
|
||||||
* while another part should be cached per request/project/workspace key.
|
|
||||||
*
|
|
||||||
* In this example:
|
|
||||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
|
||||||
* be provided once by the application runtime.
|
|
||||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
|
||||||
* cached by LayerMap for that key.
|
|
||||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
|
||||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
|
||||||
*
|
|
||||||
* Lifetime model:
|
|
||||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
|
||||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
|
||||||
* ConfigService for exactly that context key.
|
|
||||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
|
||||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
|
||||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
|
||||||
* running on the old instance; the next lookup can create a fresh instance.
|
|
||||||
*
|
|
||||||
* Key model:
|
|
||||||
* - Keys can be strings, structs, classes, arrays, etc.
|
|
||||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
|
||||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RequestContext = {
|
|
||||||
readonly directory: string
|
|
||||||
readonly workspace: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
|
||||||
"@opencode/example/RequestContextRef",
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export interface ConfigServiceShape {
|
|
||||||
readonly directory: string
|
|
||||||
readonly workspace: string
|
|
||||||
readonly nextUse: () => Effect.Effect<number>
|
|
||||||
readonly which: Npm.Interface["which"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
|
||||||
"@opencode/example/ConfigService",
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const configServiceLayer = Layer.effect(
|
|
||||||
ConfigService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const context = yield* RequestContextRef
|
|
||||||
const npm = yield* Npm.Service
|
|
||||||
|
|
||||||
let useCount = 0
|
|
||||||
|
|
||||||
return ConfigService.of({
|
|
||||||
directory: context.directory,
|
|
||||||
workspace: context.workspace,
|
|
||||||
nextUse: () => Effect.succeed(++useCount),
|
|
||||||
which: npm.which,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
|
||||||
lookup: (context: RequestContext) =>
|
|
||||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
|
||||||
idleTimeToLive: "5 minutes",
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const appLayer = ConfigServiceMap.layer
|
|
||||||
|
|
||||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
|
||||||
const config = yield* ConfigService
|
|
||||||
|
|
||||||
return {
|
|
||||||
directory: config.directory,
|
|
||||||
workspace: config.workspace,
|
|
||||||
useCount: yield* config.nextUse(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
|
||||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
|
||||||
})
|
|
||||||
|
|
||||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
|
||||||
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
refresh:
|
refresh:
|
||||||
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
|
refresh === undefined
|
||||||
|
? undefined
|
||||||
|
: (credential) => Effect.promise(() => refresh(credential)),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
remove: draft.method.remove,
|
remove: draft.method.remove,
|
||||||
@@ -263,8 +265,35 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) =>
|
hook: (name, callback) => {
|
||||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
if (name !== "http")
|
||||||
|
return register(
|
||||||
|
host.session.hook(name, (event) =>
|
||||||
|
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return register(
|
||||||
|
host.session.hook("http", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
const output = {
|
||||||
|
...event,
|
||||||
|
request: (input: Request) =>
|
||||||
|
Effect.runPromiseWith(context)(request(input), { signal: input.signal }),
|
||||||
|
}
|
||||||
|
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
|
||||||
|
Effect.tap(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.request = (input) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: (signal) => output.request(new Request(input, { signal })),
|
||||||
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
export * as OpenAICodex from "./openai-codex"
|
|
||||||
|
|
||||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
|
||||||
// Codex routing lives in ModelResolver and catalog filtering.
|
|
||||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
|
||||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
|
||||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
|
||||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
|
||||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
|
||||||
// provider entry could replace the hardcoded rules with catalog data.
|
|
||||||
|
|
||||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
|
||||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
|
||||||
|
|
||||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
|
||||||
|
|
||||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
|
||||||
type CredentialLike = {
|
|
||||||
readonly type: string
|
|
||||||
readonly methodID?: string
|
|
||||||
readonly metadata?: Record<string, unknown> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
|
||||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
|
||||||
|
|
||||||
export const accountID = (credential: CredentialLike | undefined) => {
|
|
||||||
if (!isChatGPT(credential)) return undefined
|
|
||||||
const value = credential?.metadata?.accountID
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
|
||||||
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
|
||||||
|
|
||||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
|
||||||
export const eligible = (apiID: string) => {
|
|
||||||
if (allowed.has(apiID)) return true
|
|
||||||
if (disallowed.has(apiID)) return false
|
|
||||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
|
||||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,16 @@ import { Model } from "../../model"
|
|||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
import { OpenAICodex } from "./openai-codex"
|
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const issuer = "https://auth.openai.com"
|
const issuer = "https://auth.openai.com"
|
||||||
const callbackPort = 1455
|
const callbackPort = 1455
|
||||||
const pollingSafetyMargin = 3000
|
const pollingSafetyMargin = 3000
|
||||||
|
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||||
|
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||||
|
|
||||||
type Pkce = {
|
type Pkce = {
|
||||||
verifier: string
|
verifier: string
|
||||||
@@ -164,14 +166,18 @@ export const OpenAIPlugin = define({
|
|||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let chatgpt = false
|
let chatgpt: Credential.OAuth | undefined
|
||||||
|
|
||||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||||
const connection = yield* ctx.integration.connection.active("openai")
|
const connection = yield* ctx.integration.connection.active("openai")
|
||||||
const credential = connection
|
const credential = connection
|
||||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
: undefined
|
: undefined
|
||||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
chatgpt =
|
||||||
|
credential?.type === "oauth" &&
|
||||||
|
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||||
|
? credential
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
@@ -183,16 +189,19 @@ export const OpenAIPlugin = define({
|
|||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (!Provider.isAISDK(item.provider.package)) continue
|
if (!Provider.isAISDK(item.provider.package)) continue
|
||||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||||
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
|
provider.package = "@opencode-ai/ai/providers/openai"
|
||||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
|
||||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
|
||||||
model.enabled = false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!chatgpt) return
|
if (!chatgpt) return
|
||||||
const item = evt.provider.get(Provider.ID.openai)
|
const item = evt.provider.get(Provider.ID.openai)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||||
|
const account = chatgpt.metadata?.accountID
|
||||||
|
item.provider.headers = Provider.mergeHeaders(
|
||||||
|
item.provider.headers,
|
||||||
|
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||||
|
)
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||||
// subscription covers usage, so hide the rest and zero the cost.
|
// subscription covers usage, so hide the rest and zero the cost.
|
||||||
@@ -201,14 +210,42 @@ export const OpenAIPlugin = define({
|
|||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
|
const apiID = draft.modelID ?? draft.id
|
||||||
|
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||||
|
if (
|
||||||
|
!codexAllowed.has(apiID) &&
|
||||||
|
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||||
|
) {
|
||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draft.cost = []
|
draft.cost = []
|
||||||
|
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||||
|
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
yield* ctx.session.hook("http", (evt) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
|
const request = evt.request
|
||||||
|
evt.request = (input) => {
|
||||||
|
const url = new URL(input.url)
|
||||||
|
const headers = new Headers(input.headers)
|
||||||
|
headers.set("originator", "opencode")
|
||||||
|
headers.set("session-id", evt.sessionID)
|
||||||
|
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
|
||||||
|
return request(
|
||||||
|
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
|
||||||
|
method: input.method,
|
||||||
|
headers,
|
||||||
|
body: input.body,
|
||||||
|
signal: input.signal,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
@@ -216,21 +253,6 @@ export const OpenAIPlugin = define({
|
|||||||
Stream.runForEach(refresh),
|
Stream.runForEach(refresh),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
yield* ctx.aisdk.hook(
|
|
||||||
"sdk",
|
|
||||||
Effect.fn(function* (evt) {
|
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
|
||||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
|
||||||
evt.sdk = mod.createOpenAI(evt.options)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* ctx.aisdk.hook(
|
|
||||||
"language",
|
|
||||||
Effect.fn(function* (evt) {
|
|
||||||
if (evt.model.providerID !== Provider.ID.openai) return
|
|
||||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
} satisfies PluginInternal.InternalPlugin)
|
} satisfies PluginInternal.InternalPlugin)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,17 @@ import { Session } from "../session"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly session: Pick<
|
readonly session: Pick<
|
||||||
Session.Interface,
|
Session.Interface,
|
||||||
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
|
| "get"
|
||||||
|
| "create"
|
||||||
|
| "messages"
|
||||||
|
| "prompt"
|
||||||
|
| "generate"
|
||||||
|
| "command"
|
||||||
|
| "rename"
|
||||||
|
| "resume"
|
||||||
|
| "interrupt"
|
||||||
|
| "synthetic"
|
||||||
|
| "wait"
|
||||||
>
|
>
|
||||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||||
readonly location: {
|
readonly location: {
|
||||||
@@ -52,9 +62,11 @@ export const layerWithCell = (cell: Cell) =>
|
|||||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||||
|
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||||
|
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||||
},
|
},
|
||||||
job: {
|
job: {
|
||||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||||
@@ -108,7 +120,6 @@ export const providerLayerWithCell = (cell: Cell) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWithCell(defaultCell)
|
export const layer = layerWithCell(defaultCell)
|
||||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export * as Project from "./project"
|
|||||||
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { asc, desc } from "drizzle-orm"
|
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
@@ -40,6 +40,7 @@ export interface Resolved {
|
|||||||
readonly previous?: ID
|
readonly previous?: ID
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
|
readonly canonical: AbsolutePath
|
||||||
readonly vcs?: Vcs
|
readonly vcs?: Vcs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
|
|||||||
fs: FSUtil.Interface,
|
fs: FSUtil.Interface,
|
||||||
input: AbsolutePath,
|
input: AbsolutePath,
|
||||||
) {
|
) {
|
||||||
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
|
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
|
||||||
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
||||||
Effect.catch(() => Effect.succeed(undefined)),
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
@@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
|
|||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
worktree: row.worktree,
|
canonical: row.worktree,
|
||||||
vcs: row.vcs ?? undefined,
|
vcs: row.vcs ?? undefined,
|
||||||
name: row.name ?? undefined,
|
name: row.name ?? undefined,
|
||||||
icon,
|
icon,
|
||||||
@@ -106,6 +107,40 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const projectDirectories = yield* ProjectDirectories.Service
|
const projectDirectories = yield* ProjectDirectories.Service
|
||||||
|
|
||||||
|
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||||
|
yield* db
|
||||||
|
.transaction((tx) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const vcs = project.vcs?.type
|
||||||
|
yield* tx
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: ProjectTable.id,
|
||||||
|
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||||
|
setWhere: or(
|
||||||
|
ne(ProjectTable.worktree, project.canonical),
|
||||||
|
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
if (!project.vcs) return
|
||||||
|
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||||
|
if (project.directory === project.canonical) return
|
||||||
|
yield* projectDirectories.create(
|
||||||
|
{
|
||||||
|
projectID: project.id,
|
||||||
|
directory: project.directory,
|
||||||
|
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return project
|
||||||
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Project.list")(function* () {
|
const list = Effect.fn("Project.list")(function* () {
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
@@ -189,7 +224,7 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
|
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
|
||||||
Effect.map((matches) => matches[0]),
|
Effect.map((matches) => matches[0]),
|
||||||
Effect.catch(() => Effect.succeed(undefined)),
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
)
|
)
|
||||||
@@ -211,17 +246,26 @@ const layer = Layer.effect(
|
|||||||
if (repo) {
|
if (repo) {
|
||||||
const previous = yield* cached(repo.commonDirectory)
|
const previous = yield* cached(repo.commonDirectory)
|
||||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||||
return {
|
const canonical =
|
||||||
|
repo.gitDirectory === repo.commonDirectory
|
||||||
|
? repo.worktree
|
||||||
|
: yield* git.worktree.list(repo).pipe(
|
||||||
|
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||||
|
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||||
|
)
|
||||||
|
return yield* persist({
|
||||||
previous,
|
previous,
|
||||||
id: id ?? ID.global,
|
id: id ?? ID.global,
|
||||||
directory: repo.worktree,
|
directory: repo.worktree,
|
||||||
|
canonical,
|
||||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const hg = yield* hgDiscover(input)
|
const hg = yield* hgDiscover(input)
|
||||||
if (hg) return hg
|
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
|
||||||
return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
const directory = AbsolutePath.make(path.parse(input).root)
|
||||||
|
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||||
})
|
})
|
||||||
|
|
||||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * from "./session/schema"
|
|||||||
|
|
||||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Workspace } from "./workspace"
|
import { Workspace } from "./workspace"
|
||||||
import { Model } from "./model"
|
import { Model } from "./model"
|
||||||
@@ -325,6 +325,22 @@ const layer = Layer.effect(
|
|||||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
|
const persistProject = (project: Project.Resolved) => {
|
||||||
|
const vcs = project.vcs?.type
|
||||||
|
return db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: ProjectTable.id,
|
||||||
|
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||||
|
setWhere: or(
|
||||||
|
ne(ProjectTable.worktree, project.canonical),
|
||||||
|
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
@@ -347,12 +363,7 @@ const layer = Layer.effect(
|
|||||||
if (location === undefined)
|
if (location === undefined)
|
||||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||||
const project = yield* projects.resolve(location.directory)
|
const project = yield* projects.resolve(location.directory)
|
||||||
yield* db
|
yield* persistProject(project)
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const info = SessionV1.SessionInfo.make({
|
const info = SessionV1.SessionInfo.make({
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
@@ -363,7 +374,7 @@ const layer = Layer.effect(
|
|||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||||
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
|
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
|
||||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
title: input.title,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model
|
model: input.model
|
||||||
? {
|
? {
|
||||||
@@ -451,6 +462,7 @@ const layer = Layer.effect(
|
|||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
|
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
if (input.parentID !== undefined)
|
if (input.parentID !== undefined)
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -732,12 +744,7 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
const project = yield* projects.resolve(directory)
|
const project = yield* projects.resolve(directory)
|
||||||
yield* db
|
yield* persistProject(project)
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if ((yield* execution.active).has(input.sessionID)) {
|
if ((yield* execution.active).has(input.sessionID)) {
|
||||||
yield* execution.interrupt(input.sessionID)
|
yield* execution.interrupt(input.sessionID)
|
||||||
yield* execution.awaitIdle(input.sessionID)
|
yield* execution.awaitIdle(input.sessionID)
|
||||||
@@ -931,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
|||||||
: resolved.bytes
|
: resolved.bytes
|
||||||
const normalized = yield* normalizeImageAttachment(
|
const normalized = yield* normalizeImageAttachment(
|
||||||
input,
|
input,
|
||||||
Base64.make(Buffer.from(content).toString("base64")),
|
Buffer.from(content).toString("base64"),
|
||||||
mime,
|
mime,
|
||||||
image,
|
image,
|
||||||
)
|
)
|
||||||
@@ -947,11 +954,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
|||||||
|
|
||||||
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
|
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
|
||||||
input: PromptInput.FileAttachment,
|
input: PromptInput.FileAttachment,
|
||||||
data: Base64,
|
data: string,
|
||||||
mime: string,
|
mime: string,
|
||||||
image: Effect.Effect<Image.Interface>,
|
image: Effect.Effect<Image.Interface>,
|
||||||
) {
|
) {
|
||||||
if (!mime.startsWith("image/")) return { data, mime }
|
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||||
const service = yield* image
|
const service = yield* image
|
||||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||||
|
|||||||
@@ -152,14 +152,11 @@ const settings = (documents: readonly Config.Entry[]) => {
|
|||||||
const configured = documents
|
const configured = documents
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||||
return configured.reduce<Settings>(
|
return {
|
||||||
(result, current) => ({
|
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||||
auto: current.auto ?? result.auto,
|
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
|
||||||
buffer: current.buffer ?? result.buffer,
|
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
|
||||||
tokens: current.keep?.tokens ?? result.tokens,
|
}
|
||||||
}),
|
|
||||||
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (
|
const select = (
|
||||||
@@ -350,11 +347,16 @@ const make = (dependencies: Dependencies) => {
|
|||||||
message.type === "assistant" && message.tokens !== undefined,
|
message.type === "assistant" && message.tokens !== undefined,
|
||||||
)
|
)
|
||||||
if (!last) return false
|
if (!last) return false
|
||||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
const limits = input.model.route.defaults.limits
|
||||||
|
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||||
|
const promptCeiling = Math.min(
|
||||||
|
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
|
||||||
|
context - Math.max(output, config.buffer),
|
||||||
|
)
|
||||||
const used =
|
const used =
|
||||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
if (used <= 0) return false
|
if (used <= 0) return false
|
||||||
return used >= context - (output || config.buffer)
|
return used >= promptCeiling
|
||||||
}
|
}
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||||
const content = planContent(input.messages, config.tokens)
|
const content = planContent(input.messages, config.tokens)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
|
|||||||
LLM.request({
|
LLM.request({
|
||||||
model: model.model,
|
model: model.model,
|
||||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||||
providerOptions: { openai: { promptCacheKey } },
|
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||||
system: contextEvent.system,
|
system: contextEvent.system,
|
||||||
messages: contextEvent.messages,
|
messages: contextEvent.messages,
|
||||||
tools: hookedTools,
|
tools: hookedTools,
|
||||||
|
|||||||
@@ -117,21 +117,20 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
|||||||
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
|
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
/** Returns the session's first user message. */
|
||||||
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
|
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const rows = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
|
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
|
||||||
.orderBy(asc(SessionMessageTable.seq))
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
.limit(2)
|
.get()
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (rows.length !== 1) return undefined
|
if (!row) return undefined
|
||||||
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
return message?.type === "user" ? message : undefined
|
return message?.type === "user" ? message : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
|||||||
return SessionSchema.Info.make({
|
return SessionSchema.Info.make({
|
||||||
id: SessionSchema.ID.make(row.id),
|
id: SessionSchema.ID.make(row.id),
|
||||||
projectID: Project.ID.make(row.project_id),
|
projectID: Project.ID.make(row.project_id),
|
||||||
title: row.title,
|
title: row.title ?? undefined,
|
||||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||||
fork:
|
fork:
|
||||||
row.fork_session_id && row.fork_boundary
|
row.fork_session_id && row.fork_boundary
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as SessionModelRequest from "./model-request"
|
export * as SessionModelRequest from "./model-request"
|
||||||
|
|
||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||||
@@ -18,6 +19,11 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
|||||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||||
import { toLLMMessages } from "./runner/to-llm-message"
|
import { toLLMMessages } from "./runner/to-llm-message"
|
||||||
|
|
||||||
|
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||||
|
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||||
|
const IMAGE_REMOVED =
|
||||||
|
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||||
|
|
||||||
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
||||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
|||||||
|
|
||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
|
readonly options: StreamOptions
|
||||||
/**
|
/**
|
||||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||||
* step-limit-violating calls fail individually through the same seam.
|
* step-limit-violating calls fail individually through the same seam.
|
||||||
@@ -92,6 +99,56 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||||
|
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
|
||||||
|
const size = (data: string | Uint8Array) =>
|
||||||
|
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
|
||||||
|
const imageBytes = messages.reduce(
|
||||||
|
(total, message) =>
|
||||||
|
total +
|
||||||
|
message.content.reduce((sum, part) => {
|
||||||
|
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
|
||||||
|
if (part.type !== "tool-result" || part.result.type !== "content") return sum
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
part.result.value.reduce(
|
||||||
|
(bytes: number, item: Content) =>
|
||||||
|
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
|
||||||
|
|
||||||
|
let removed = 0
|
||||||
|
return messages.map((message) =>
|
||||||
|
Message.make({
|
||||||
|
...message,
|
||||||
|
content: message.content.map((part) => {
|
||||||
|
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
|
||||||
|
removed += size(part.data)
|
||||||
|
return Message.text(IMAGE_REMOVED)
|
||||||
|
}
|
||||||
|
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
result: {
|
||||||
|
...part.result,
|
||||||
|
value: part.result.value.map((item: Content) => {
|
||||||
|
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
|
||||||
|
return item
|
||||||
|
removed += Buffer.byteLength(item.uri)
|
||||||
|
return { type: "text" as const, text: IMAGE_REMOVED }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an outbound model request and captures the tool-call capability that
|
* Builds an outbound model request and captures the tool-call capability that
|
||||||
* must remain paired with it. It does not execute the request or mutate
|
* must remain paired with it. It does not execute the request or mutate
|
||||||
@@ -156,12 +213,23 @@ export const layer = Layer.effect(
|
|||||||
http: {
|
http: {
|
||||||
headers: SessionModelHeaders.make(session, app),
|
headers: SessionModelHeaders.make(session, app),
|
||||||
},
|
},
|
||||||
providerOptions: { openai: { promptCacheKey } },
|
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||||
system: contextEvent.system,
|
system: contextEvent.system,
|
||||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||||
tools: hookedTools,
|
tools: hookedTools,
|
||||||
toolChoice: stepLimitReached ? "none" : undefined,
|
toolChoice: stepLimitReached ? "none" : undefined,
|
||||||
})
|
})
|
||||||
|
const options: StreamOptions = {
|
||||||
|
http: (request, handler) =>
|
||||||
|
hooks
|
||||||
|
.trigger("session", "http", {
|
||||||
|
sessionID: session.id,
|
||||||
|
agent: agent.id,
|
||||||
|
model: resolved.ref,
|
||||||
|
request: handler,
|
||||||
|
})
|
||||||
|
.pipe(Effect.flatMap((event) => event.request(request))),
|
||||||
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
const current = PromptCacheDiagnostics.snapshot(request)
|
const current = PromptCacheDiagnostics.snapshot(request)
|
||||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||||
@@ -190,6 +258,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
|
options,
|
||||||
executeTool,
|
executeTool,
|
||||||
stepLimitReached,
|
stepLimitReached,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ type Usage = {
|
|||||||
|
|
||||||
const ForkBatchSize = 500
|
const ForkBatchSize = 500
|
||||||
|
|
||||||
const forkTitle = (value: string) => {
|
const forkTitle = (value?: string) => {
|
||||||
|
if (value === undefined) return
|
||||||
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
|
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
|
||||||
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
|
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
|
||||||
return `${value} (fork #1)`
|
return `${value} (fork #1)`
|
||||||
@@ -216,7 +217,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
directory: parent.directory,
|
directory: parent.directory,
|
||||||
path: parent.path,
|
path: parent.path,
|
||||||
title: forkTitle(parent.title),
|
title: forkTitle(parent.title ?? undefined),
|
||||||
agent: parent.agent,
|
agent: parent.agent,
|
||||||
model: parent.model,
|
model: parent.model,
|
||||||
version: parent.version,
|
version: parent.version,
|
||||||
@@ -657,6 +658,12 @@ const layer = Layer.effectDiscard(
|
|||||||
input: event.data.input,
|
input: event.data.input,
|
||||||
timeCreated: event.created,
|
timeCreated: event.created,
|
||||||
})
|
})
|
||||||
|
yield* db
|
||||||
|
.update(SessionTable)
|
||||||
|
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||||
|
|||||||
@@ -93,10 +93,9 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
const titlesRunning = new Set<SessionSchema.ID>()
|
||||||
const titleStarted = new Set<SessionSchema.ID>()
|
|
||||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
/**
|
/**
|
||||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||||
@@ -125,7 +124,7 @@ const layer = Layer.effect(
|
|||||||
let step = 1
|
let step = 1
|
||||||
while (true) {
|
while (true) {
|
||||||
const result = yield* runStep(sessionID, promotable, step)
|
const result = yield* runStep(sessionID, promotable, step)
|
||||||
yield* startTitleOnce(sessionID)
|
if (step === 1) yield* startTitle(sessionID)
|
||||||
yield* runPendingCompaction(sessionID)
|
yield* runPendingCompaction(sessionID)
|
||||||
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
||||||
promotable = "steer"
|
promotable = "steer"
|
||||||
@@ -279,7 +278,7 @@ const layer = Layer.effect(
|
|||||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||||
// context-overflow provider error so settlement may recover it via compaction.
|
// context-overflow provider error so settlement may recover it via compaction.
|
||||||
let overflowFailure: ProviderErrorEvent | undefined
|
let overflowFailure: ProviderErrorEvent | undefined
|
||||||
const providerStream = llm.stream(prepared.request).pipe(
|
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
|
||||||
Stream.runForEach((event) =>
|
Stream.runForEach((event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (overflowFailure || publisher.hasProviderError()) return
|
if (overflowFailure || publisher.hasProviderError()) return
|
||||||
@@ -481,11 +480,20 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Fires title generation once per process after the first step makes a user message visible. */
|
/** Starts one title request at a time after a successful step makes user input visible. */
|
||||||
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||||
if (titleStarted.has(sessionID)) return
|
if (titlesRunning.has(sessionID)) return
|
||||||
titleStarted.add(sessionID)
|
titlesRunning.add(sessionID)
|
||||||
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
|
forkTitle(
|
||||||
|
title.generateForFirstPrompt(sessionID).pipe(
|
||||||
|
Effect.ignore,
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
titlesRunning.delete(sessionID)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const SessionTable = sqliteTable(
|
|||||||
slug: text().notNull(),
|
slug: text().notNull(),
|
||||||
directory: directoryColumn().notNull(),
|
directory: directoryColumn().notNull(),
|
||||||
path: pathColumn(),
|
path: pathColumn(),
|
||||||
title: text().notNull(),
|
title: text(),
|
||||||
version: text().notNull(),
|
version: text().notNull(),
|
||||||
share_url: text(),
|
share_url: text(),
|
||||||
summary_additions: integer(),
|
summary_additions: integer(),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
export * as SessionTitle from "./title"
|
export * as SessionTitle from "./title"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { Agent } from "../agent"
|
import { Agent } from "../agent"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import { App } from "../app"
|
import { App } from "../app"
|
||||||
import { llmClient } from "../effect/app-node-platform"
|
import { llmClient } from "../effect/app-node-platform"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
@@ -14,8 +15,10 @@ import { SessionModelHeaders } from "./model-headers"
|
|||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionUsage } from "./usage"
|
import { SessionUsage } from "./usage"
|
||||||
|
import { SessionStore } from "./store"
|
||||||
|
|
||||||
const MAX_LENGTH = 100
|
const MAX_LENGTH = 100
|
||||||
|
const titleChanged = Symbol("Session title changed")
|
||||||
|
|
||||||
type Dependencies = {
|
type Dependencies = {
|
||||||
readonly app: App.Info
|
readonly app: App.Info
|
||||||
@@ -25,24 +28,33 @@ type Dependencies = {
|
|||||||
}
|
}
|
||||||
readonly agents: Agent.Interface
|
readonly agents: Agent.Interface
|
||||||
readonly models: SessionRunnerModel.Interface
|
readonly models: SessionRunnerModel.Interface
|
||||||
|
readonly store: SessionStore.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
|
/** Generates a title from the session's first user message when the session remains untitled. */
|
||||||
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
|
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
|
||||||
|
|
||||||
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
||||||
|
const isUntitled = (session: SessionSchema.Info) =>
|
||||||
|
isExactRootFallback({
|
||||||
|
title: session.title,
|
||||||
|
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||||
|
})
|
||||||
|
|
||||||
const make = (dependencies: Dependencies) => {
|
const make = (dependencies: Dependencies) => {
|
||||||
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
||||||
db: Database.Interface["db"],
|
db: Database.Interface["db"],
|
||||||
session: SessionSchema.Info,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
|
const session = yield* dependencies.store.get(sessionID)
|
||||||
|
if (!session) return
|
||||||
if (session.parentID) return
|
if (session.parentID) return
|
||||||
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
|
if (!isUntitled(session)) return
|
||||||
|
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||||
if (!firstUser) return
|
if (!firstUser) return
|
||||||
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
|
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
|
||||||
if (!agent) return
|
if (!agent) return
|
||||||
@@ -96,10 +108,19 @@ const make = (dependencies: Dependencies) => {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.find((line) => line.length > 0)
|
.find((line) => line.length > 0)
|
||||||
if (!title) return
|
if (!title) return
|
||||||
yield* dependencies.bus.publish(SessionEvent.Renamed, {
|
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||||
sessionID: session.id,
|
const current = yield* dependencies.store.get(sessionID)
|
||||||
title: truncate(title),
|
if (!current || !isUntitled(current)) return
|
||||||
})
|
yield* dependencies.bus
|
||||||
|
.publish(
|
||||||
|
SessionEvent.Renamed,
|
||||||
|
{
|
||||||
|
sessionID: session.id,
|
||||||
|
title: truncate(title),
|
||||||
|
},
|
||||||
|
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||||
|
)
|
||||||
|
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||||
})
|
})
|
||||||
return { generateForFirstPrompt }
|
return { generateForFirstPrompt }
|
||||||
}
|
}
|
||||||
@@ -111,11 +132,12 @@ export const layer = Layer.effect(
|
|||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
const store = yield* SessionStore.Service
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const app = yield* App.Metadata
|
const app = yield* App.Metadata
|
||||||
const title = make({ bus, llm, agents, models, app })
|
const title = make({ bus, llm, agents, models, store, app })
|
||||||
return Service.of({
|
return Service.of({
|
||||||
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -123,5 +145,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
|
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||||||
if (cause instanceof LLMError) {
|
if (cause instanceof LLMError) {
|
||||||
switch (cause.reason._tag) {
|
switch (cause.reason._tag) {
|
||||||
case "RateLimit":
|
case "RateLimit":
|
||||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
return providerError("provider.rate-limit", cause.reason)
|
||||||
case "Authentication":
|
case "Authentication":
|
||||||
return { type: "provider.auth", message: cause.reason.message }
|
return providerError("provider.auth", cause.reason)
|
||||||
case "QuotaExceeded":
|
case "QuotaExceeded":
|
||||||
return { type: "provider.quota", message: cause.reason.message }
|
return providerError("provider.quota", cause.reason)
|
||||||
case "ContentPolicy":
|
case "ContentPolicy":
|
||||||
return { type: "provider.content-filter", message: cause.reason.message }
|
return providerError("provider.content-filter", cause.reason)
|
||||||
case "Transport":
|
case "Transport":
|
||||||
return { type: "provider.transport", message: cause.reason.message }
|
return providerError("provider.transport", cause.reason)
|
||||||
case "ProviderInternal":
|
case "ProviderInternal":
|
||||||
return { type: "provider.internal", message: cause.reason.message }
|
return providerError("provider.internal", cause.reason)
|
||||||
case "InvalidProviderOutput":
|
case "InvalidProviderOutput":
|
||||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
return providerError("provider.invalid-output", cause.reason)
|
||||||
case "InvalidRequest":
|
case "InvalidRequest":
|
||||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
return providerError("provider.invalid-request", cause.reason)
|
||||||
case "NoRoute":
|
case "NoRoute":
|
||||||
return { type: "provider.no-route", message: cause.reason.message }
|
return providerError("provider.no-route", cause.reason)
|
||||||
case "UnknownProvider":
|
case "UnknownProvider":
|
||||||
return { type: "provider.unknown", message: cause.reason.message }
|
return providerError("provider.unknown", cause.reason)
|
||||||
default: {
|
default: {
|
||||||
const exhaustive: never = cause.reason
|
const exhaustive: never = cause.reason
|
||||||
return exhaustive
|
return exhaustive
|
||||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||||
|
const status =
|
||||||
|
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||||
|
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
export * as ShellParse from "./parse"
|
||||||
|
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import type { Node } from "web-tree-sitter"
|
||||||
|
import { shellParserWasm } from "#shell-parser-wasm"
|
||||||
|
import { ShellSelect } from "./select"
|
||||||
|
|
||||||
|
type Part = { type: string; text: string }
|
||||||
|
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||||
|
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||||
|
|
||||||
|
const ARITY: Record<string, number> = {
|
||||||
|
cat: 1,
|
||||||
|
cd: 1,
|
||||||
|
chmod: 1,
|
||||||
|
chown: 1,
|
||||||
|
cp: 1,
|
||||||
|
echo: 1,
|
||||||
|
env: 1,
|
||||||
|
export: 1,
|
||||||
|
grep: 1,
|
||||||
|
kill: 1,
|
||||||
|
killall: 1,
|
||||||
|
ln: 1,
|
||||||
|
ls: 1,
|
||||||
|
mkdir: 1,
|
||||||
|
mv: 1,
|
||||||
|
ps: 1,
|
||||||
|
pwd: 1,
|
||||||
|
rm: 1,
|
||||||
|
rmdir: 1,
|
||||||
|
sleep: 1,
|
||||||
|
source: 1,
|
||||||
|
tail: 1,
|
||||||
|
touch: 1,
|
||||||
|
unset: 1,
|
||||||
|
which: 1,
|
||||||
|
aws: 3,
|
||||||
|
az: 3,
|
||||||
|
bazel: 2,
|
||||||
|
brew: 2,
|
||||||
|
bun: 2,
|
||||||
|
"bun run": 3,
|
||||||
|
"bun x": 3,
|
||||||
|
cargo: 2,
|
||||||
|
"cargo add": 3,
|
||||||
|
"cargo run": 3,
|
||||||
|
cdk: 2,
|
||||||
|
cf: 2,
|
||||||
|
cmake: 2,
|
||||||
|
composer: 2,
|
||||||
|
consul: 2,
|
||||||
|
"consul kv": 3,
|
||||||
|
crictl: 2,
|
||||||
|
deno: 2,
|
||||||
|
"deno task": 3,
|
||||||
|
doctl: 3,
|
||||||
|
docker: 2,
|
||||||
|
"docker builder": 3,
|
||||||
|
"docker compose": 3,
|
||||||
|
"docker container": 3,
|
||||||
|
"docker image": 3,
|
||||||
|
"docker network": 3,
|
||||||
|
"docker volume": 3,
|
||||||
|
eksctl: 2,
|
||||||
|
"eksctl create": 3,
|
||||||
|
firebase: 2,
|
||||||
|
flyctl: 2,
|
||||||
|
gcloud: 3,
|
||||||
|
gh: 3,
|
||||||
|
git: 2,
|
||||||
|
"git config": 3,
|
||||||
|
"git remote": 3,
|
||||||
|
"git stash": 3,
|
||||||
|
go: 2,
|
||||||
|
gradle: 2,
|
||||||
|
helm: 2,
|
||||||
|
heroku: 2,
|
||||||
|
hugo: 2,
|
||||||
|
ip: 2,
|
||||||
|
"ip addr": 3,
|
||||||
|
"ip link": 3,
|
||||||
|
"ip netns": 3,
|
||||||
|
"ip route": 3,
|
||||||
|
kind: 2,
|
||||||
|
"kind create": 3,
|
||||||
|
kubectl: 2,
|
||||||
|
"kubectl kustomize": 3,
|
||||||
|
"kubectl rollout": 3,
|
||||||
|
kustomize: 2,
|
||||||
|
make: 2,
|
||||||
|
mc: 2,
|
||||||
|
"mc admin": 3,
|
||||||
|
minikube: 2,
|
||||||
|
mongosh: 2,
|
||||||
|
mysql: 2,
|
||||||
|
mvn: 2,
|
||||||
|
ng: 2,
|
||||||
|
npm: 2,
|
||||||
|
"npm exec": 3,
|
||||||
|
"npm init": 3,
|
||||||
|
"npm run": 3,
|
||||||
|
"npm view": 3,
|
||||||
|
npx: 2,
|
||||||
|
nvm: 2,
|
||||||
|
nx: 2,
|
||||||
|
openssl: 2,
|
||||||
|
"openssl req": 3,
|
||||||
|
"openssl x509": 3,
|
||||||
|
pip: 2,
|
||||||
|
pipenv: 2,
|
||||||
|
pnpm: 2,
|
||||||
|
"pnpm dlx": 3,
|
||||||
|
"pnpm exec": 3,
|
||||||
|
"pnpm run": 3,
|
||||||
|
poetry: 2,
|
||||||
|
podman: 2,
|
||||||
|
"podman container": 3,
|
||||||
|
"podman image": 3,
|
||||||
|
psql: 2,
|
||||||
|
pulumi: 2,
|
||||||
|
"pulumi stack": 3,
|
||||||
|
python: 2,
|
||||||
|
pyenv: 2,
|
||||||
|
rake: 2,
|
||||||
|
rbenv: 2,
|
||||||
|
"redis-cli": 2,
|
||||||
|
rustup: 2,
|
||||||
|
serverless: 2,
|
||||||
|
sfdx: 3,
|
||||||
|
skaffold: 2,
|
||||||
|
sls: 2,
|
||||||
|
sst: 2,
|
||||||
|
swift: 2,
|
||||||
|
systemctl: 2,
|
||||||
|
terraform: 2,
|
||||||
|
"terraform workspace": 3,
|
||||||
|
tmux: 2,
|
||||||
|
turbo: 2,
|
||||||
|
ufw: 2,
|
||||||
|
vault: 2,
|
||||||
|
"vault auth": 3,
|
||||||
|
"vault kv": 3,
|
||||||
|
vercel: 2,
|
||||||
|
volta: 2,
|
||||||
|
wp: 2,
|
||||||
|
yarn: 2,
|
||||||
|
"yarn dlx": 3,
|
||||||
|
"yarn run": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||||
|
const parsers = yield* Effect.promise(load)
|
||||||
|
const powershell = ShellSelect.ps(shell)
|
||||||
|
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||||
|
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||||
|
|
||||||
|
return yield* Effect.acquireUseRelease(
|
||||||
|
Effect.succeed(tree),
|
||||||
|
(tree) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
tree.rootNode.descendantsOfType("command").reduce(
|
||||||
|
(result, node) => {
|
||||||
|
if (!node) return result
|
||||||
|
const command = parts(node)
|
||||||
|
const tokens = command.map((part) => part.text)
|
||||||
|
if (tokens.length === 0) return result
|
||||||
|
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||||
|
if (CWD.has(name)) {
|
||||||
|
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.commands.push({
|
||||||
|
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||||
|
save: `${prefix(tokens).join(" ")} *`,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(tree) => Effect.sync(() => tree.delete()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function parts(node: Node) {
|
||||||
|
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||||
|
const child = node.child(index)
|
||||||
|
if (!child) return []
|
||||||
|
if (child.type === "command_elements")
|
||||||
|
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||||
|
const item = child.child(itemIndex)
|
||||||
|
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||||
|
return [{ type: item.type, text: item.text }]
|
||||||
|
})
|
||||||
|
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||||
|
return []
|
||||||
|
return [{ type: child.type, text: child.text }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||||
|
if (!powershell)
|
||||||
|
return command
|
||||||
|
.slice(1)
|
||||||
|
.filter((part) => !part.text.startsWith("-"))
|
||||||
|
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
|
||||||
|
.filter((part) => part !== undefined)
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
let path = false
|
||||||
|
for (const part of command.slice(1)) {
|
||||||
|
if (path) {
|
||||||
|
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||||
|
if (value) directories.push(value)
|
||||||
|
path = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (part.type === "command_parameter") {
|
||||||
|
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||||
|
if (value) directories.push(value)
|
||||||
|
}
|
||||||
|
return directories
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryArgument(value: string, powershell: boolean, cwd: string, shell: string) {
|
||||||
|
const quote = value[0]
|
||||||
|
const text = (quote === '"' || quote === "'") && value.at(-1) === quote ? value.slice(1, -1) : value
|
||||||
|
if (!powershell) return expandKnownDirectory(text)
|
||||||
|
|
||||||
|
// PowerShell exposes environment variables through $env:NAME and provides these
|
||||||
|
// automatic directory variables. Expand only values we can determine without executing code.
|
||||||
|
return expandKnownDirectory(
|
||||||
|
text
|
||||||
|
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => environment(key) ?? "")
|
||||||
|
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => environment(key) ?? "")
|
||||||
|
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => {
|
||||||
|
if (key.toUpperCase() === "HOME") return os.homedir()
|
||||||
|
if (key.toUpperCase() === "PWD") return cwd
|
||||||
|
return path.dirname(shell)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandKnownDirectory(value: string) {
|
||||||
|
// Unknown shell expressions cannot be resolved safely during permission analysis.
|
||||||
|
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||||
|
if (value === "~") return os.homedir()
|
||||||
|
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function environment(key: string) {
|
||||||
|
if (process.platform !== "win32") return process.env[key]
|
||||||
|
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
||||||
|
return name ? process.env[name] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefix(tokens: string[]) {
|
||||||
|
for (let length = tokens.length; length > 0; length--) {
|
||||||
|
const arity = ARITY[tokens.slice(0, length).join(" ")]
|
||||||
|
if (arity !== undefined) return tokens.slice(0, arity)
|
||||||
|
}
|
||||||
|
return tokens.slice(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(asset: string) {
|
||||||
|
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||||
|
if (path.isAbsolute(asset)) return asset
|
||||||
|
return fileURLToPath(new URL(asset, import.meta.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (() => {
|
||||||
|
let loading: ReturnType<typeof initialize> | undefined
|
||||||
|
return () => (loading ??= initialize())
|
||||||
|
})()
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
const { Parser, Language } = await import("web-tree-sitter")
|
||||||
|
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
|
||||||
|
const [bashLanguage, psLanguage] = await Promise.all([
|
||||||
|
Language.load(resolve(shellParserWasm.bash)),
|
||||||
|
Language.load(resolve(shellParserWasm.powershell)),
|
||||||
|
])
|
||||||
|
const bash = new Parser()
|
||||||
|
bash.setLanguage(bashLanguage)
|
||||||
|
const ps = new Parser()
|
||||||
|
ps.setLanguage(psLanguage)
|
||||||
|
return { bash, ps }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user