mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 23:16:21 +00:00
Compare commits
206
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c553a08b7 | ||
|
|
33dd4e3ba8 | ||
|
|
955fcad647 | ||
|
|
ec4a5cbe25 | ||
|
|
79c789be89 | ||
|
|
429387d158 | ||
|
|
268ca2f63d | ||
|
|
6051a1f987 | ||
|
|
c35be481b3 | ||
|
|
45e2035c0b | ||
|
|
74fbe199af | ||
|
|
fe4ea1d693 | ||
|
|
d57e210f84 | ||
|
|
44a3bf2520 | ||
|
|
998086d6fb | ||
|
|
6ea388a206 | ||
|
|
651cdd257a | ||
|
|
e3c2e635a9 | ||
|
|
91a4c7bc32 | ||
|
|
eead95e712 | ||
|
|
c2a7616beb | ||
|
|
48c8a308b9 | ||
|
|
1bd1f72bcf | ||
|
|
311e32da93 | ||
|
|
90501dd6e3 | ||
|
|
499e22bf52 | ||
|
|
dfe3052bb6 | ||
|
|
fa4f8a66c2 | ||
|
|
9391ee8efc | ||
|
|
6d6e2a9f68 | ||
|
|
fa6fb71a83 | ||
|
|
8068c5e48c | ||
|
|
8525035bbe | ||
|
|
2c3f94f0ba | ||
|
|
745a1c0ee6 | ||
|
|
7188e22bdc | ||
|
|
8fc93e6ee4 | ||
|
|
6e87cd66bf | ||
|
|
8f97a0986a | ||
|
|
fbcc2d9855 | ||
|
|
b4447e6be8 | ||
|
|
3cfec5ab34 | ||
|
|
b467432ba4 | ||
|
|
8d4ef01621 | ||
|
|
335e4ca56f | ||
|
|
e327f93711 | ||
|
|
a978a1e010 | ||
|
|
e561431f7c | ||
|
|
519cd8c771 | ||
|
|
34e40cc4bc | ||
|
|
e0c7712f20 | ||
|
|
ddda404d99 | ||
|
|
0b771030ed | ||
|
|
4d0eb97ddf | ||
|
|
d117a33982 | ||
|
|
46c33630b7 | ||
|
|
74eda7f950 | ||
|
|
01093db365 | ||
|
|
95d788f8eb | ||
|
|
49f9a60087 | ||
|
|
48927df2ff | ||
|
|
2de99a2885 | ||
|
|
c8f81c8b83 | ||
|
|
f92a725dfd | ||
|
|
7f2645a8f4 | ||
|
|
5ee7f19875 | ||
|
|
e76e90b71e | ||
|
|
c806503694 | ||
|
|
fd73a85de4 | ||
|
|
e2e82f18e2 | ||
|
|
91cdb182f0 | ||
|
|
316f7925d3 | ||
|
|
9f01e2b548 | ||
|
|
7856515140 | ||
|
|
818804e181 | ||
|
|
ce6247bd2f | ||
|
|
0465328297 | ||
|
|
43d09b9d75 | ||
|
|
7730cec123 | ||
|
|
367ee47d7e | ||
|
|
c596a8f11d | ||
|
|
30f998c5b9 | ||
|
|
d4b4dd17cc | ||
|
|
f330f3e02b | ||
|
|
a9c27209bb | ||
|
|
831f8f6cd1 | ||
|
|
cff1d0fe01 | ||
|
|
e297da82ae | ||
|
|
77eac47493 | ||
|
|
aadc0c1b4b | ||
|
|
94caa36fd4 | ||
|
|
a6f75d483a | ||
|
|
000b42d204 | ||
|
|
02440f6715 | ||
|
|
d46ed9e9db | ||
|
|
71779ae5de | ||
|
|
4a3e25beab | ||
|
|
23fde448ec | ||
|
|
1137861188 | ||
|
|
815d4ab9b4 | ||
|
|
5d73a5789f | ||
|
|
df05945042 | ||
|
|
6a99898ef7 | ||
|
|
a40a87276a | ||
|
|
a20cbc394e | ||
|
|
8fda87614f | ||
|
|
dffd95ce7c | ||
|
|
b0402f5a34 | ||
|
|
54b00ec5fe | ||
|
|
6dd1733bbf | ||
|
|
663c2dc1ce | ||
|
|
01eda4c178 | ||
|
|
a6b49b3f74 | ||
|
|
5b2276666f | ||
|
|
cc0cc59700 | ||
|
|
57a9decefe | ||
|
|
c0220ddd8b | ||
|
|
b31defc0a5 | ||
|
|
e7d42f83e6 | ||
|
|
db768c4886 | ||
|
|
9553187ba6 | ||
|
|
d04257eeb4 | ||
|
|
d68f425c17 | ||
|
|
49dd2cea34 | ||
|
|
fac875dba0 | ||
|
|
566ca864a0 | ||
|
|
5df9cecf03 | ||
|
|
a68fe8a97d | ||
|
|
c17c104827 | ||
|
|
5d4cc4a804 | ||
|
|
1f04baa684 | ||
|
|
3e9b009642 | ||
|
|
36ac35a7c8 | ||
|
|
197d28e033 | ||
|
|
fcce2d7cc9 | ||
|
|
ec0dcb3da9 | ||
|
|
afd7492018 | ||
|
|
9517ff1054 | ||
|
|
1ced747051 | ||
|
|
43819dc376 | ||
|
|
e15dd8ecd3 | ||
|
|
6a38cacc1d | ||
|
|
d609752891 | ||
|
|
5894e46688 | ||
|
|
327dc809c5 | ||
|
|
e9f7331516 | ||
|
|
8be3ce8b6c | ||
|
|
30721b8b5d | ||
|
|
eb083cce63 | ||
|
|
1afb7c614e | ||
|
|
56e773831c | ||
|
|
6b1ed3918a | ||
|
|
1b3eb1138e | ||
|
|
711a0a2da2 | ||
|
|
ac77cc46b8 | ||
|
|
7197fdfb4e | ||
|
|
5a4914c670 | ||
|
|
3a797bf6e4 | ||
|
|
33536da231 | ||
|
|
e56ceed32b | ||
|
|
b2c7246134 | ||
|
|
9c39e75ce2 | ||
|
|
0dad76e618 | ||
|
|
90fb6562ce | ||
|
|
174d263890 | ||
|
|
5ec29e7a87 | ||
|
|
3c6b85acf7 | ||
|
|
50e77f66fd | ||
|
|
d484f070d1 | ||
|
|
8890294bf0 | ||
|
|
a1925de0c1 | ||
|
|
24e826d06b | ||
|
|
19625400c1 | ||
|
|
52cbe7b3ee | ||
|
|
6a2c3e91c7 | ||
|
|
4afd8e81be | ||
|
|
485bdc9c4e | ||
|
|
1a3aee39de | ||
|
|
2ddf257c6f | ||
|
|
ef50e0b6d8 | ||
|
|
583a1a2b6f | ||
|
|
68c1207b52 | ||
|
|
f77647ad12 | ||
|
|
c746ea3210 | ||
|
|
d323b34826 | ||
|
|
bf50194f99 | ||
|
|
4a977b2b31 | ||
|
|
b1e3a7b222 | ||
|
|
b12d43698e | ||
|
|
5cd2c22b27 | ||
|
|
c2ef80a287 | ||
|
|
4df3029536 | ||
|
|
e70d667a9f | ||
|
|
8ba434b597 | ||
|
|
171947787c | ||
|
|
106629aa11 | ||
|
|
3ee2e482ce | ||
|
|
849824efd2 | ||
|
|
cf2c3a536d | ||
|
|
7852cecd72 | ||
|
|
6cfffeb031 | ||
|
|
6e954f75ee | ||
|
|
0116a98371 | ||
|
|
a38cbd42aa | ||
|
|
4ab31867c4 | ||
|
|
51a082cea3 |
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- production
|
||||
- beta
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -15,7 +16,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
@@ -28,6 +29,7 @@ jobs:
|
||||
node-version: "24"
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||
role-session-name: opencode-${{ github.run_id }}
|
||||
|
||||
@@ -417,7 +417,6 @@ jobs:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name == 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
@@ -480,7 +479,7 @@ jobs:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
@@ -46,6 +46,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
|
||||
- Avoid `try`/`catch` where possible
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -183,6 +184,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
@@ -416,7 +418,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.1",
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
@@ -430,6 +432,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
@@ -589,8 +592,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -727,6 +730,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
@@ -966,6 +970,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -975,6 +980,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1044,6 +1050,7 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
@@ -1073,9 +1080,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -1590,7 +1597,7 @@
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
|
||||
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
|
||||
|
||||
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
|
||||
|
||||
@@ -1640,6 +1647,10 @@
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
||||
|
||||
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
|
||||
|
||||
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
|
||||
|
||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
@@ -1678,7 +1689,7 @@
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
|
||||
"@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
|
||||
"@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
|
||||
|
||||
@@ -2212,27 +2223,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2774,7 +2785,7 @@
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="],
|
||||
|
||||
@@ -2874,6 +2885,8 @@
|
||||
|
||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
|
||||
|
||||
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
|
||||
@@ -3202,8 +3215,6 @@
|
||||
|
||||
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
@@ -3532,7 +3543,7 @@
|
||||
|
||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
@@ -3812,7 +3823,7 @@
|
||||
|
||||
"dot-prop": ["dot-prop@8.0.2", "", { "dependencies": { "type-fest": "^3.8.0" } }, "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||
|
||||
@@ -3884,7 +3895,7 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -4206,7 +4217,7 @@
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
@@ -5300,6 +5311,10 @@
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
|
||||
|
||||
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
@@ -5314,6 +5329,8 @@
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
|
||||
@@ -5436,8 +5453,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -5652,8 +5667,6 @@
|
||||
|
||||
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
|
||||
|
||||
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -5802,9 +5815,7 @@
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -5812,7 +5823,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -5836,8 +5847,6 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
@@ -5960,6 +5969,8 @@
|
||||
|
||||
"@astrojs/starlight/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
|
||||
@@ -6114,7 +6125,9 @@
|
||||
|
||||
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
@@ -6150,6 +6163,12 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
|
||||
|
||||
"@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"@npmcli/config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@npmcli/package-json/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.14", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-bgWgiSfFS689/AxQDarU+b3Qu1FYewfVr5vI/jhV84s7GQ3C2OsdRxukGGYKB37MCgwcS50UrfBLlHzhiG13sw=="],
|
||||
@@ -6238,6 +6257,8 @@
|
||||
|
||||
"@opencode-ai/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
|
||||
|
||||
"@opencode-ai/stats-app/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
@@ -6290,12 +6311,16 @@
|
||||
|
||||
"@pierre/trees/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"@sentry/bundler-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
@@ -6368,14 +6393,6 @@
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"app-builder-lib/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -6386,6 +6403,8 @@
|
||||
|
||||
"astro/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
|
||||
|
||||
"astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"astro/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
@@ -6430,6 +6449,8 @@
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -6456,14 +6477,16 @@
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"electron/@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
|
||||
|
||||
"electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
@@ -6510,7 +6533,7 @@
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
@@ -6556,10 +6579,10 @@
|
||||
|
||||
"nitro/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-package-arg/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -6580,8 +6603,6 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
@@ -6616,6 +6637,8 @@
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
|
||||
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
|
||||
|
||||
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6642,8 +6665,6 @@
|
||||
|
||||
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||
|
||||
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
|
||||
@@ -6786,6 +6807,8 @@
|
||||
|
||||
"@astrojs/starlight/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"@astrojs/starlight/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@astrojs/starlight/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"@astrojs/starlight/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -6908,6 +6931,8 @@
|
||||
|
||||
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
@@ -7130,6 +7155,8 @@
|
||||
|
||||
"@opencode-ai/web/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"@opencode-ai/web/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@opencode-ai/web/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"@opencode-ai/web/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -7234,14 +7261,6 @@
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
@@ -7260,6 +7279,8 @@
|
||||
|
||||
"astro-expressive-code/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"astro-expressive-code/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"astro-expressive-code/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"astro-expressive-code/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -7394,6 +7415,10 @@
|
||||
|
||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
@@ -7402,6 +7427,8 @@
|
||||
|
||||
"gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -7450,6 +7477,12 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -7476,6 +7509,8 @@
|
||||
|
||||
"toolbeam-docs-theme/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -8154,8 +8189,6 @@
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
+2
-9
@@ -1,4 +1,5 @@
|
||||
import { domain } from "./stage"
|
||||
import { createWebApp } from "./webapp"
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
|
||||
},
|
||||
})
|
||||
|
||||
new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain: "app." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
// Preserve Sentry credentials and run source-map uploads on every deployment.
|
||||
command: "bun run build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
createWebApp("app." + domain)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function createWebApp(domain: string) {
|
||||
return new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain,
|
||||
path: "packages/app",
|
||||
environment:
|
||||
$app.stage === "beta"
|
||||
? {
|
||||
OPENCODE_CHANNEL: "beta",
|
||||
VITE_SENTRY_ENVIRONMENT: "beta",
|
||||
}
|
||||
: undefined,
|
||||
build: {
|
||||
// Preserve Sentry credentials and run source-map uploads on every deployment.
|
||||
command: "bun run build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
|
||||
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
|
||||
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
|
||||
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -5,10 +5,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -178,6 +178,7 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
+102
-1
@@ -241,14 +241,115 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Provider compaction
|
||||
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
|
||||
|
||||
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
```ts
|
||||
const result = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
|
||||
|
||||
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
|
||||
|
||||
Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint. Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
|
||||
messages,
|
||||
providerOptions: {
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
```
|
||||
|
||||
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
|
||||
|
||||
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
|
||||
|
||||
```ts
|
||||
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
|
||||
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
|
||||
```
|
||||
|
||||
For Anthropic, use:
|
||||
|
||||
```ts
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 150_000 },
|
||||
pauseAfterCompaction: true,
|
||||
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
|
||||
}],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
|
||||
- Custom instructions replace Anthropic's default summarization instructions.
|
||||
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
|
||||
- A pause is exposed as `response.finishReason.raw === "compaction"`. It occurs only if the threshold triggers compaction: `pauseAfterCompaction` does not mean "compact now". The caller explicitly issues the next request; the package never automatically resumes.
|
||||
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
|
||||
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
|
||||
|
||||
### Ownership and verification
|
||||
|
||||
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
|
||||
|
||||
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
|
||||
|
||||
```sh
|
||||
# Run from packages/ai. Only records the selected new cassette group.
|
||||
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
```
|
||||
|
||||
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
@@ -6,8 +6,12 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
@@ -15,7 +19,6 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -61,6 +64,8 @@ export type ThinkingInput =
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
@@ -89,6 +94,23 @@ export interface OptionsInput {
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("input_tokens"),
|
||||
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
|
||||
}),
|
||||
),
|
||||
pauseAfterCompaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
@@ -236,7 +258,12 @@ const AnthropicUserBlock = Schema.Union([
|
||||
AnthropicToolResultBlock,
|
||||
])
|
||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicCompactionBlock = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
})
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicCompactionBlock,
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
@@ -312,6 +339,18 @@ const AnthropicContainer = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: ContextManagement.fields.edits.value.fields.trigger,
|
||||
pause_after_compaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
model: Schema.String,
|
||||
system: optionalArray(AnthropicTextBlock),
|
||||
messages: Schema.Array(AnthropicMessage),
|
||||
@@ -335,7 +374,7 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: optionalNull(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
@@ -354,6 +393,13 @@ const AnthropicUsage = Schema.StructWithRest(
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...AnthropicIterationUsage.schema.fields,
|
||||
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -377,6 +423,7 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
@@ -406,6 +453,8 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly provider: LLMRequest["model"]["provider"]
|
||||
readonly compactions: Readonly<Record<number, string | null>>
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -831,6 +880,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
const content: AnthropicUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
@@ -840,14 +890,21 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
if (content.length > 0) messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
if (part.provider !== request.model.provider || part.text === undefined)
|
||||
return yield* invalid("Compaction state must be replayed to its originating provider and API")
|
||||
content.push({ type: "compaction", content: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
@@ -891,7 +948,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
|
||||
)
|
||||
}
|
||||
messages.push({ role: "assistant", content })
|
||||
if (content.length > 0) messages.push({ role: "assistant", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1001,6 +1058,9 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1019,10 +1079,11 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const systemParts = request.system.filter((part) => part.text.length > 0)
|
||||
const system =
|
||||
request.system.length === 0
|
||||
systemParts.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
: systemParts.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
@@ -1034,7 +1095,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
return {
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -1055,6 +1116,18 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
...body,
|
||||
context_management: {
|
||||
edits: management.edits.map((edit) => ({
|
||||
type: edit.type,
|
||||
trigger: edit.trigger,
|
||||
pause_after_compaction: edit.pauseAfterCompaction,
|
||||
instructions: edit.instructions,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -1076,18 +1149,31 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const iterations = usage.iterations?.length ? usage.iterations : [usage]
|
||||
const last = usage.iterations?.at(-1)
|
||||
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
|
||||
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
|
||||
const cacheWrite = ProviderShared.sumTokens(
|
||||
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
|
||||
)
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
outputTokens,
|
||||
contextTokens:
|
||||
last?.type === "message"
|
||||
? ProviderShared.sumTokens(
|
||||
last.input_tokens ?? undefined,
|
||||
last.cache_read_input_tokens ?? undefined,
|
||||
last.cache_creation_input_tokens ?? undefined,
|
||||
)
|
||||
: undefined,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1109,6 +1195,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens: right.contextTokens ?? left.contextTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
@@ -1167,7 +1254,6 @@ const onContentBlockStart = (
|
||||
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
|
||||
): StepResult => {
|
||||
const block = event.content_block
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
|
||||
@@ -1262,7 +1348,16 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
) {
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
if (delta.type === "compaction_delta") {
|
||||
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
|
||||
return [
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "text_delta" && delta.text) {
|
||||
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1271,7 +1366,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
if (delta.type === "thinking_delta" && delta.thinking) {
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1283,7 +1378,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
if (delta.type === "signature_delta" && delta.signature) {
|
||||
const index = event.index ?? 0
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
return [
|
||||
@@ -1295,7 +1390,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (delta.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
@@ -1320,6 +1415,18 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
event: AnthropicEvent,
|
||||
) {
|
||||
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (event.index in state.compactions) {
|
||||
const { [event.index]: content, ...compactions } = state.compactions
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.compaction({
|
||||
provider: state.provider,
|
||||
text: content,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
@@ -1343,31 +1450,51 @@ const onMessageDelta = (
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const pendingFinish = (() => {
|
||||
const stopReason = event.delta?.stop_reason
|
||||
if (stopReason === null || stopReason === undefined) return state.pendingFinish
|
||||
|
||||
const stopSequence = event.delta?.stop_sequence
|
||||
const finishMetadata =
|
||||
stopSequence === null || stopSequence === undefined
|
||||
? state.pendingFinish?.providerMetadata
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence })
|
||||
return {
|
||||
reason: {
|
||||
normalized: mapFinishReason(stopReason),
|
||||
raw: stopReason,
|
||||
},
|
||||
providerMetadata: finishMetadata,
|
||||
}
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
pendingFinish,
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
if (Object.keys(state.compactions).length)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
const finished = Lifecycle.finish(lifecycle, events, {
|
||||
const closed = Object.entries(state.reasoningSignatures).reduce(
|
||||
(current, [index, signature]) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
current,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
providerMetadata(state.providerMetadataKey, { signature }),
|
||||
),
|
||||
lifecycle,
|
||||
)
|
||||
const finished = Lifecycle.finish(closed, events, {
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
@@ -1397,16 +1524,21 @@ const onError = (event: AnthropicEvent) => {
|
||||
)
|
||||
}
|
||||
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
type === "thinking" ||
|
||||
type === "redacted_thinking" ||
|
||||
type === "tool_use" ||
|
||||
type === "server_tool_use" ||
|
||||
isServerToolResultType(type)
|
||||
|
||||
const isKnownStreamDeltaType = (type: string) =>
|
||||
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
|
||||
const STREAM_BLOCK_TYPES = new Set([
|
||||
"compaction",
|
||||
"text",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"tool_use",
|
||||
"server_tool_use",
|
||||
])
|
||||
const STREAM_DELTA_TYPES = new Set([
|
||||
"compaction_delta",
|
||||
"text_delta",
|
||||
"thinking_delta",
|
||||
"signature_delta",
|
||||
"input_json_delta",
|
||||
])
|
||||
|
||||
const invalidStreamEvent = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
@@ -1435,7 +1567,16 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_start") {
|
||||
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
|
||||
return invalidStreamEvent(event)
|
||||
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
if (event.content_block.type === "compaction") {
|
||||
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
|
||||
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
return Effect.succeed<StepResult>([
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
|
||||
NO_EVENTS,
|
||||
])
|
||||
}
|
||||
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamBlock(event.content_block)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
const block = decoded.value
|
||||
@@ -1449,7 +1590,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
}
|
||||
if (event.type === "content_block_delta") {
|
||||
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
|
||||
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
|
||||
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
@@ -1483,6 +1624,8 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: (request) => ({
|
||||
provider: request.model.provider,
|
||||
compactions: {},
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
@@ -1492,6 +1635,37 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
|
||||
const http = HttpTransport.httpJson<Body, string>({ framing })
|
||||
return {
|
||||
...http,
|
||||
prepare: (input: Parameters<typeof http.prepare>[0]) => {
|
||||
if (
|
||||
!input.body.context_management?.edits.length &&
|
||||
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
return http.prepare(input)
|
||||
const headers = Headers.fromInput(input.request.http?.headers)
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
return http.prepare({
|
||||
...input,
|
||||
request: LLMRequest.update(input.request, {
|
||||
http: new HttpOptions({
|
||||
...input.request.http,
|
||||
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
@@ -1501,7 +1675,7 @@ export const route = Route.make({
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
transport: transport<AnthropicMessagesBody>(),
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -262,24 +262,28 @@ const providerMetadata = (key: string, metadata: Record<string, unknown>): Provi
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
)
|
||||
if (part.encrypted !== undefined) return part.encrypted
|
||||
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
|
||||
}
|
||||
|
||||
const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
if (Array.isArray(input)) return input.map(removeEmptyToolInputKeys)
|
||||
if (!ProviderShared.isRecord(input)) return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).flatMap(([key, value]) => (key === "" ? [] : [[key, removeEmptyToolInputKeys(value)]])),
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
input: part.input,
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -414,7 +418,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
return content.length === 0 ? undefined : content
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
@@ -422,38 +431,42 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0
|
||||
? {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const toolConfig = (() => {
|
||||
if (request.tools.length === 0) return undefined
|
||||
return {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
}
|
||||
})()
|
||||
const system = lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig:
|
||||
const inferenceConfig = (() => {
|
||||
if (
|
||||
generation?.maxTokens === undefined &&
|
||||
generation?.temperature === undefined &&
|
||||
generation?.topP === undefined &&
|
||||
(generation?.stop === undefined || generation.stop.length === 0)
|
||||
? undefined
|
||||
: {
|
||||
maxTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
},
|
||||
)
|
||||
return undefined
|
||||
return {
|
||||
maxTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig,
|
||||
toolConfig,
|
||||
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
|
||||
// as a model-specific field, so it goes through additionalModelRequestFields.
|
||||
@@ -469,7 +482,6 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -498,12 +510,23 @@ interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
|
||||
// one finish after both chunks have had a chance to arrive.
|
||||
readonly finishReason: FinishReasonDetails | undefined
|
||||
readonly usage: Usage | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
|
||||
}
|
||||
|
||||
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => {
|
||||
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
|
||||
chunks.reduce((offset, chunk) => {
|
||||
bytes.set(chunk, offset)
|
||||
return offset + chunk.length
|
||||
}, 0)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: BedrockEvent) =>
|
||||
@@ -551,23 +574,46 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const metadata = reasoning.signature
|
||||
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || metadata !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
: state.lifecycle
|
||||
const redactedChunks = yield* (() => {
|
||||
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
|
||||
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
|
||||
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
|
||||
undefined,
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
})()
|
||||
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
|
||||
const metadata = (() => {
|
||||
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
})()
|
||||
const lifecycle = (() => {
|
||||
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
|
||||
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
})()
|
||||
const reasoningRedactedContent = (() => {
|
||||
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
|
||||
if (reasoning.data === undefined) return state.reasoningRedactedContent
|
||||
return Object.fromEntries(
|
||||
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
|
||||
)
|
||||
})()
|
||||
const reasoningSignatures = (() => {
|
||||
if (!reasoning.signature) return state.reasoningSignatures
|
||||
return { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
})()
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningSignatures: reasoning.signature
|
||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||
: state.reasoningSignatures,
|
||||
reasoningSignatures,
|
||||
reasoningRedactedContent,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -595,16 +641,24 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
state.reasoningSignatures[index]
|
||||
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
|
||||
: undefined,
|
||||
)
|
||||
const lifecycle = (() => {
|
||||
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
|
||||
const metadata = (() => {
|
||||
const signature = state.reasoningSignatures[index]
|
||||
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
|
||||
const redactedContent = state.reasoningRedactedContent[index]
|
||||
if (redactedContent)
|
||||
return providerMetadata(state.providerMetadataKey, {
|
||||
redactedData: encodeRedactedContent(redactedContent),
|
||||
})
|
||||
})()
|
||||
return Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
metadata,
|
||||
)
|
||||
})()
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
{
|
||||
@@ -618,21 +672,30 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
reasoningRedactedContent: Object.fromEntries(
|
||||
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
|
||||
),
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.messageStop) {
|
||||
if (
|
||||
event.messageStop.stopReason === "malformed_model_output" ||
|
||||
event.messageStop.stopReason === "malformed_tool_use"
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
`Bedrock Converse stopped with ${event.messageStop.stopReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
usage: state.pendingFinish?.usage,
|
||||
finishReason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
},
|
||||
[],
|
||||
@@ -640,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
pendingFinish: {
|
||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||
usage,
|
||||
},
|
||||
usage,
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
@@ -670,23 +730,22 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
|
||||
const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.pendingFinish.reason,
|
||||
normalized:
|
||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||
? "tool-calls"
|
||||
: state.pendingFinish.reason.normalized,
|
||||
},
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
if (!state.finishReason) return []
|
||||
const normalized = (() => {
|
||||
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
|
||||
return state.finishReason.normalized
|
||||
})()
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.finishReason,
|
||||
normalized,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And Bedrock Route
|
||||
@@ -707,10 +766,12 @@ export const protocol = Protocol.make({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
pendingFinish: undefined,
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningSignatures: {},
|
||||
reasoningRedactedContent: {},
|
||||
}),
|
||||
step,
|
||||
onHalt: (state) => Effect.succeed(onHalt(state)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { Effect, Encoding, Stream } from "effect"
|
||||
import { AIError, AIErrorReason } from "../schema/index.js"
|
||||
import { AIError, AIErrorReason, InvalidProviderOutputError } from "../schema/index.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
@@ -22,6 +22,10 @@ interface FrameBufferState {
|
||||
|
||||
const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 }
|
||||
|
||||
type FrameInput = { readonly _tag: "Chunk"; readonly bytes: Uint8Array } | { readonly _tag: "End" }
|
||||
|
||||
const endOfStream: FrameInput = { _tag: "End" }
|
||||
|
||||
const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => {
|
||||
const remaining = state.buffer.length - state.offset
|
||||
// Compact: drop the consumed prefix and append the new chunk in one alloc.
|
||||
@@ -33,9 +37,23 @@ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferSta
|
||||
return { buffer: next, offset: 0 }
|
||||
}
|
||||
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
|
||||
const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameInput) =>
|
||||
Effect.gen(function* () {
|
||||
let cursor = appendChunk(state, chunk)
|
||||
if (input._tag === "End") {
|
||||
const remaining = state.buffer.subarray(state.offset)
|
||||
if (remaining.length > 0)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route,
|
||||
classification: "incomplete-stream",
|
||||
message: `Incomplete Bedrock Converse event-stream frame: ${remaining.length} buffered bytes remain at end of stream`,
|
||||
body: Encoding.encodeBase64(remaining),
|
||||
}),
|
||||
})
|
||||
return [state, []] as const
|
||||
}
|
||||
|
||||
let cursor = appendChunk(state, input.bytes)
|
||||
const out: object[] = []
|
||||
while (cursor.buffer.length - cursor.offset >= 4) {
|
||||
const view = cursor.buffer.subarray(cursor.offset)
|
||||
@@ -113,7 +131,12 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
export const framing = (route: string): Framing.Definition<object> => ({
|
||||
id: "aws-event-stream",
|
||||
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
|
||||
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
|
||||
frame: (bytes) =>
|
||||
bytes.pipe(
|
||||
Stream.map((bytes): FrameInput => ({ _tag: "Chunk", bytes })),
|
||||
Stream.concat(Stream.succeed(endOfStream)),
|
||||
Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route)),
|
||||
),
|
||||
})
|
||||
|
||||
export * as BedrockEventStream from "./bedrock-event-stream.js"
|
||||
|
||||
@@ -240,6 +240,10 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly reasoningId?: string
|
||||
readonly textId?: string
|
||||
readonly nextReasoningId: number
|
||||
readonly nextTextId: number
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -571,19 +575,23 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = state.lifecycle
|
||||
if (state.reasoningSignature !== undefined)
|
||||
if (state.reasoningId !== undefined)
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
state.reasoningId,
|
||||
state.reasoningSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
if (state.textId !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
state.textId,
|
||||
state.textSignature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
@@ -601,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
|
||||
if (ProviderShared.isRecord(event.error)) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
message:
|
||||
typeof event.error.message === "string" && event.error.message.length > 0
|
||||
? event.error.message
|
||||
: typeof event.error.status === "string" && event.error.status.length > 0
|
||||
? event.error.status
|
||||
: "Gemini provider error",
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if ("error" in event)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
|
||||
)
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
@@ -632,6 +649,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
let reasoningId = nextState.reasoningId
|
||||
let textId = nextState.textId
|
||||
let nextReasoningId = nextState.nextReasoningId
|
||||
let nextTextId = nextState.nextTextId
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
@@ -657,27 +678,51 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
if (textId !== undefined) {
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
textId,
|
||||
textSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
|
||||
: undefined,
|
||||
)
|
||||
textId = undefined
|
||||
textSignature = undefined
|
||||
}
|
||||
if (reasoningId === undefined) {
|
||||
reasoningId = `reasoning-${nextReasoningId}`
|
||||
nextReasoningId += 1
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningId,
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
if (reasoningId !== undefined) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
reasoningId,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
reasoningId = undefined
|
||||
reasoningSignature = undefined
|
||||
}
|
||||
if (textId === undefined) {
|
||||
textId = `text-${nextTextId}`
|
||||
nextTextId += 1
|
||||
}
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
textId,
|
||||
part.text,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
@@ -695,14 +740,28 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
if (reasoningId !== undefined) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
reasoningId,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
reasoningId = undefined
|
||||
reasoningSignature = undefined
|
||||
}
|
||||
if (textId !== undefined) {
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
textId,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textId = undefined
|
||||
textSignature = undefined
|
||||
}
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
@@ -725,6 +784,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
reasoningId,
|
||||
textId,
|
||||
nextReasoningId,
|
||||
nextTextId,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
@@ -752,6 +815,8 @@ export const protocol = Protocol.make({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
nextReasoningId: 0,
|
||||
nextTextId: 0,
|
||||
}),
|
||||
step,
|
||||
onHalt: (state) => Effect.succeed(finish(state)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as AnthropicMessages from "./anthropic-messages.js"
|
||||
export * as BedrockConverse from "./bedrock-converse.js"
|
||||
export * as Gemini from "./gemini.js"
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
export * as OpenAIChat from "./openai-chat.js"
|
||||
export * as OpenAIImages from "./openai-images.js"
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
const DONE = "[DONE]" as const
|
||||
const TOOL_ID = /^[A-Za-z0-9]{9}$/
|
||||
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
const MistralTextContent = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const MistralThinkingUnit = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralThinkingUnit = Schema.Schema.Type<typeof MistralThinkingUnit>
|
||||
|
||||
const MistralThinkingContent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("thinking"),
|
||||
thinking: Schema.Array(MistralThinkingUnit),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralThinkingContent = Schema.Schema.Type<typeof MistralThinkingContent>
|
||||
const isMistralThinkingContent = Schema.is(MistralThinkingContent)
|
||||
|
||||
const MistralUserContent = Schema.Union([
|
||||
MistralTextContent,
|
||||
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("document_url"), document_url: Schema.String }),
|
||||
])
|
||||
type MistralUserContent = Schema.Schema.Type<typeof MistralUserContent>
|
||||
|
||||
const MistralAssistantToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
|
||||
})
|
||||
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
|
||||
|
||||
const MistralMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(Schema.Union([MistralTextContent, MistralThinkingContent]))]),
|
||||
tool_calls: optionalArray(MistralAssistantToolCall),
|
||||
prefix: Schema.optional(Schema.Literal(true)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type MistralMessage = Schema.Schema.Type<typeof MistralMessage>
|
||||
|
||||
const MistralTool = Schema.Struct({
|
||||
type: Schema.Literal("function"),
|
||||
function: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
strict: Schema.Literal(false),
|
||||
}),
|
||||
})
|
||||
type MistralTool = Schema.Schema.Type<typeof MistralTool>
|
||||
|
||||
const MistralOptions = Schema.Struct({
|
||||
safePrompt: Schema.optional(Schema.Boolean),
|
||||
documentImageLimit: Schema.optional(Schema.Number),
|
||||
documentPageLimit: Schema.optional(Schema.Number),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
promptMode: Schema.optional(Schema.Literal("reasoning")),
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | (string & {})
|
||||
|
||||
export type ProviderOptionsInput = {
|
||||
readonly safePrompt?: boolean
|
||||
readonly documentImageLimit?: number
|
||||
readonly documentPageLimit?: number
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly promptMode?: "reasoning"
|
||||
readonly promptCacheKey?: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
const MistralBody = Schema.Struct({
|
||||
model: Schema.String,
|
||||
messages: Schema.Array(MistralMessage),
|
||||
tools: optionalArray(MistralTool),
|
||||
tool_choice: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literals(["auto", "none", "any"]),
|
||||
Schema.Struct({ type: Schema.Literal("function"), function: Schema.Struct({ name: Schema.String }) }),
|
||||
]),
|
||||
),
|
||||
stream: Schema.Literal(true),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
random_seed: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
frequency_penalty: Schema.optional(Schema.Number),
|
||||
presence_penalty: Schema.optional(Schema.Number),
|
||||
stop: optionalArray(Schema.String),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
safe_prompt: Schema.optional(Schema.Boolean),
|
||||
document_image_limit: Schema.optional(Schema.Number),
|
||||
document_page_limit: Schema.optional(Schema.Number),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(Schema.String),
|
||||
prompt_mode: Schema.optional(Schema.Literal("reasoning")),
|
||||
})
|
||||
export type MistralBody = Schema.Schema.Type<typeof MistralBody>
|
||||
|
||||
const MistralUsageDetails = Schema.StructWithRest(Schema.Struct({ cached_tokens: optionalNull(Schema.Number) }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
])
|
||||
|
||||
const MistralUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
num_cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_token_details: optionalNull(MistralUsageDetails),
|
||||
prompt_tokens_details: optionalNull(MistralUsageDetails),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralOutputContent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
text: optionalNull(Schema.String),
|
||||
thinking: optionalNull(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralOutputContent = Schema.Schema.Type<typeof MistralOutputContent>
|
||||
|
||||
const MistralToolDelta = Schema.Struct({
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(
|
||||
Schema.Struct({
|
||||
name: optionalNull(Schema.String),
|
||||
arguments: optionalNull(Schema.Union([Schema.String, JsonObject])),
|
||||
}),
|
||||
),
|
||||
})
|
||||
type MistralToolDelta = Schema.Schema.Type<typeof MistralToolDelta>
|
||||
|
||||
const MistralChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.Union([Schema.String, Schema.Array(MistralOutputContent)])),
|
||||
tool_calls: optionalNull(Schema.Array(MistralToolDelta)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralError = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const MistralEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(MistralChoice)),
|
||||
usage: optionalNull(MistralUsage),
|
||||
error: optionalNull(MistralError),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const hashID = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
const toolIDNormalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (TOOL_ID.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hashID(id)
|
||||
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
|
||||
if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
|
||||
if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url }
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
|
||||
const content: MistralUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* lowerMedia(part))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): MistralAssistantToolCall => ({
|
||||
id: normalizeID(part.id),
|
||||
type: "function",
|
||||
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
|
||||
})
|
||||
|
||||
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
prefix: boolean,
|
||||
) {
|
||||
const structured = message.content.some(
|
||||
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
|
||||
)
|
||||
const content: Array<Schema.Schema.Type<typeof MistralTextContent> | MistralThinkingContent> = []
|
||||
const text: string[] = []
|
||||
const toolCalls: MistralAssistantToolCall[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
if (structured) content.push({ type: "text", text: part.text })
|
||||
else text.push(part.text)
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const native = part.providerMetadata?.mistral?.thinking
|
||||
if (structured && isMistralThinkingContent(native)) content.push(native)
|
||||
else if (structured) content.push({ type: "text", text: part.text })
|
||||
else text.push(part.text)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
toolCalls.push(lowerToolCall(part, normalizeID))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "assistant", ["text", "reasoning", "tool-call"])
|
||||
}
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: structured ? content : text.join(""),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
...(prefix ? { prefix: true as const } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
const output: MistralMessage[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
output.push({
|
||||
role: "tool",
|
||||
tool_call_id: normalizeID(part.id),
|
||||
name: part.name,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: MistralUserContent[] = []
|
||||
for (const item of part.result.value) {
|
||||
if (item.type === "text") {
|
||||
content.push({ type: "text", text: item.text })
|
||||
continue
|
||||
}
|
||||
content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))
|
||||
}
|
||||
output.push({
|
||||
role: "tool",
|
||||
tool_call_id: normalizeID(part.id),
|
||||
name: part.name,
|
||||
content: content.some((item) => item.type !== "text")
|
||||
? content
|
||||
: content.map((item) => (item.type === "text" ? item.text : "")).join(""),
|
||||
})
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = toolIDNormalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: update.text,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.role === "user") {
|
||||
messages.push(yield* lowerUser(message))
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const hasToolCalls = message.content.some((part) => part.type === "tool-call")
|
||||
const hasNativeThinking = message.content.some(
|
||||
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
|
||||
)
|
||||
const text = message.content
|
||||
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
|
||||
.join("")
|
||||
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
|
||||
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
|
||||
continue
|
||||
}
|
||||
messages.push(...(yield* lowerToolResults(message, normalizeID)))
|
||||
}
|
||||
return messages
|
||||
})
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): MistralTool => ({
|
||||
type: "function",
|
||||
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
|
||||
})
|
||||
|
||||
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
|
||||
if (request.toolChoice?.type === "tool" && !selected)
|
||||
return yield* ProviderShared.invalidRequest("Mistral Chat tool choice requires a tool name")
|
||||
if (options.reasoningEffort !== undefined && options.promptMode !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Mistral Chat reasoningEffort and promptMode provider options are mutually exclusive",
|
||||
)
|
||||
const toolChoice = request.toolChoice
|
||||
? yield* ProviderShared.matchToolChoice("Mistral Chat", request.toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "any" as const,
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: request.generation?.maxTokens,
|
||||
random_seed: request.generation?.seed,
|
||||
temperature: request.generation?.temperature,
|
||||
top_p: request.generation?.topP,
|
||||
frequency_penalty: request.generation?.frequencyPenalty,
|
||||
presence_penalty: request.generation?.presencePenalty,
|
||||
stop: request.generation?.stop,
|
||||
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
|
||||
safe_prompt: options.safePrompt,
|
||||
document_image_limit: options.documentImageLimit,
|
||||
document_page_limit: options.documentPageLimit,
|
||||
parallel_tool_calls:
|
||||
options.parallelToolCalls ?? (request.toolChoice?.disableParallelToolUse === true ? false : undefined),
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
prompt_mode: options.promptMode,
|
||||
}
|
||||
})
|
||||
|
||||
type ToolKey = string | number
|
||||
interface PendingTool {
|
||||
readonly id: string
|
||||
readonly name?: string
|
||||
readonly input: string
|
||||
}
|
||||
|
||||
interface ActiveContent {
|
||||
readonly type: "text" | "reasoning"
|
||||
readonly id: string
|
||||
readonly thinking?: MistralThinkingContent
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly tools: ToolStream.State<ToolKey>
|
||||
readonly pendingTools: Partial<Record<ToolKey, PendingTool>>
|
||||
readonly toolIDs: ReadonlyMap<string, string>
|
||||
readonly usedToolIDs: ReadonlySet<string>
|
||||
readonly completedTools: ReadonlyArray<LLMEvent>
|
||||
readonly latestToolKey?: ToolKey
|
||||
readonly generatedTools: number
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly active?: ActiveContent
|
||||
readonly nextContent: number
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
}
|
||||
|
||||
const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const reported =
|
||||
usage.num_cached_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
usage.prompt_token_details?.cached_tokens ??
|
||||
undefined
|
||||
const cached = input === undefined || reported === undefined ? undefined : Math.max(0, Math.min(input, reported))
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
return new Usage({
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
|
||||
cacheReadInputTokens: cached,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { mistral: usage },
|
||||
})
|
||||
}
|
||||
|
||||
const mapFinishReason = (reason: string) => {
|
||||
switch (reason) {
|
||||
case "stop":
|
||||
return "stop" as const
|
||||
case "length":
|
||||
case "model_length":
|
||||
return "length" as const
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
case "content_filter":
|
||||
return "content-filter" as const
|
||||
case "error":
|
||||
case "network_error":
|
||||
return "error" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingUnits = (value: unknown): ReadonlyArray<MistralThinkingUnit> => {
|
||||
if (typeof value === "string") return [{ type: "text", text: value }]
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(Schema.is(MistralThinkingUnit))
|
||||
}
|
||||
|
||||
const thinkingText = (thinking: ReadonlyArray<MistralThinkingUnit>) =>
|
||||
thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("")
|
||||
|
||||
const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } })
|
||||
|
||||
const closeActive = (state: ParserState, events: LLMEvent[]) => {
|
||||
if (!state.active) return state
|
||||
const lifecycle =
|
||||
state.active.type === "text"
|
||||
? Lifecycle.textEnd(state.lifecycle, events, state.active.id)
|
||||
: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
state.active.id,
|
||||
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
|
||||
thinkingText(state.active.thinking?.thinking ?? []),
|
||||
)
|
||||
return { ...state, lifecycle, active: undefined }
|
||||
}
|
||||
|
||||
const appendText = (state: ParserState, events: LLMEvent[], text: string) => {
|
||||
if (text.length === 0) return state
|
||||
const current = state.active?.type === "text" ? state : closeActive(state, events)
|
||||
const active = current.active ?? { type: "text" as const, id: `text-${current.nextContent}` }
|
||||
return {
|
||||
...current,
|
||||
lifecycle: Lifecycle.textDelta(current.lifecycle, events, active.id, text),
|
||||
active,
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOutputContent) => {
|
||||
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
|
||||
const units = thinkingUnits(part.thinking)
|
||||
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
|
||||
const thinking = {
|
||||
...active.thinking,
|
||||
...part,
|
||||
type: "thinking" as const,
|
||||
thinking: [...(active.thinking?.thinking ?? []), ...units],
|
||||
}
|
||||
const text = thinkingText(units)
|
||||
return {
|
||||
...current,
|
||||
lifecycle:
|
||||
text.length > 0
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
|
||||
active: { ...active, thinking },
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appendContent = (
|
||||
state: ParserState,
|
||||
events: LLMEvent[],
|
||||
content: string | ReadonlyArray<MistralOutputContent>,
|
||||
) => {
|
||||
if (typeof content === "string") return appendText(state, events, content)
|
||||
return content.reduce((current, part) => {
|
||||
if (part.type === "text") return appendText(current, events, part.text ?? "")
|
||||
if (part.type === "thinking") return appendThinking(current, events, part)
|
||||
return closeActive(current, events)
|
||||
}, state)
|
||||
}
|
||||
|
||||
const normalizeStreamToolID = (state: ParserState, source: string) => {
|
||||
if (TOOL_ID.test(source))
|
||||
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
|
||||
const previous = state.toolIDs.get(source)
|
||||
if (previous) return { id: previous, state }
|
||||
let attempt = 0
|
||||
let id = hashID(source)
|
||||
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
|
||||
return {
|
||||
id,
|
||||
state: {
|
||||
...state,
|
||||
toolIDs: new Map([...state.toolIDs, [source, id]]),
|
||||
usedToolIDs: new Set([...state.usedToolIDs, id]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const toolText = (tool: MistralToolDelta) => {
|
||||
const value = tool.function?.arguments
|
||||
if (typeof value === "string") return value
|
||||
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
|
||||
}
|
||||
|
||||
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
|
||||
initial: ParserState,
|
||||
events: LLMEvent[],
|
||||
deltas: ReadonlyArray<MistralToolDelta>,
|
||||
) {
|
||||
if (deltas.length === 0) return initial
|
||||
let state = closeActive(initial, events)
|
||||
for (const [position, delta] of deltas.entries()) {
|
||||
const wireID = delta.id?.trim() || undefined
|
||||
const providedID = wireID === "null" ? undefined : wireID
|
||||
const key =
|
||||
delta.index ??
|
||||
(providedID
|
||||
? `id:${providedID}`
|
||||
: deltas.length > 1
|
||||
? `position:${position}`
|
||||
: (state.latestToolKey ?? `missing:${state.generatedTools}`))
|
||||
const existing = state.tools[key]
|
||||
const pending = state.pendingTools[key]
|
||||
const source = providedID ?? `generated:${String(key)}`
|
||||
const normalized =
|
||||
existing || pending ? { id: existing?.id ?? pending?.id ?? "", state } : normalizeStreamToolID(state, source)
|
||||
state = normalized.state
|
||||
const name = existing?.name ?? pending?.name ?? (delta.function?.name?.trim() || undefined)
|
||||
const text = `${pending?.input ?? ""}${toolText(delta)}`
|
||||
if (!name) {
|
||||
state = {
|
||||
...state,
|
||||
pendingTools: { ...state.pendingTools, [key]: { id: normalized.id, input: text } },
|
||||
latestToolKey: key,
|
||||
generatedTools: state.generatedTools + (!providedID && !pending ? 1 : 0),
|
||||
}
|
||||
continue
|
||||
}
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
key,
|
||||
{ id: normalized.id, name, text },
|
||||
"Mistral Chat tool call delta is missing a name",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (result.events.length > 0) state = { ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }
|
||||
events.push(...result.events)
|
||||
const pendingTools = { ...state.pendingTools }
|
||||
delete pendingTools[key]
|
||||
state = {
|
||||
...state,
|
||||
tools: result.tools,
|
||||
pendingTools,
|
||||
latestToolKey: key,
|
||||
generatedTools: state.generatedTools + (!providedID && !existing && !pending ? 1 : 0),
|
||||
}
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
const hasLateContent = (event: MistralEvent) => {
|
||||
const delta = event.choices?.[0]?.delta
|
||||
if (typeof delta?.content === "string" && delta.content.length > 0) return true
|
||||
if (Array.isArray(delta?.content) && delta.content.length > 0) return true
|
||||
return (delta?.tool_calls ?? []).some(
|
||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || tool.function?.arguments !== undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
if (state.finishReason) {
|
||||
if (hasLateContent(event))
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Mistral Chat received content after the finish reason",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [{ ...state, usage }, events] as const
|
||||
}
|
||||
const choice = event.choices?.[0]
|
||||
const withContent = choice?.delta?.content == null ? state : appendContent(state, events, choice.delta.content)
|
||||
const withTools = yield* appendTools(withContent, events, choice?.delta?.tool_calls ?? [])
|
||||
if (!choice?.finish_reason) return [{ ...withTools, usage }, events] as const
|
||||
|
||||
const finishReason = {
|
||||
normalized: mapFinishReason(choice.finish_reason),
|
||||
raw: choice.finish_reason,
|
||||
}
|
||||
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
|
||||
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Mistral Chat tool call delta is missing a name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
const finished =
|
||||
!incomplete && Object.keys(withTools.tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, withTools.tools)
|
||||
: undefined
|
||||
return [
|
||||
{
|
||||
...withTools,
|
||||
tools: finished?.tools ?? withTools.tools,
|
||||
completedTools: finished?.events ?? withTools.completedTools,
|
||||
usage,
|
||||
finishReason,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
|
||||
if (!state.finishReason)
|
||||
return yield* new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Mistral Chat stream ended without finish_reason",
|
||||
classification: "incomplete-stream",
|
||||
route: ADAPTER,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const closed = closeActive(state, events)
|
||||
const lifecycle = closed.completedTools.length > 0 ? Lifecycle.stepStart(closed.lifecycle, events) : closed.lifecycle
|
||||
events.push(...closed.completedTools)
|
||||
const reason =
|
||||
state.finishReason.normalized === "stop" && closed.completedTools.some(LLMEvent.is.toolCall)
|
||||
? { ...state.finishReason, normalized: "tool-calls" as const }
|
||||
: state.finishReason
|
||||
Lifecycle.finish(lifecycle, events, { reason, usage: closed.usage })
|
||||
return events
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: { schema: MistralBody, from: fromRequest },
|
||||
stream: {
|
||||
event: MistralStreamEvent,
|
||||
initial: (): ParserState => ({
|
||||
tools: ToolStream.empty<ToolKey>(),
|
||||
pendingTools: {},
|
||||
toolIDs: new Map(),
|
||||
usedToolIDs: new Set(),
|
||||
completedTools: [],
|
||||
generatedTools: 0,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
nextContent: 0,
|
||||
}),
|
||||
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
|
||||
terminal: (event) => event === DONE,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
})
|
||||
|
||||
export const framing = Framing.sseWithDone
|
||||
export const httpTransport = HttpTransport.sseJson.with<MistralBody>().with({ framing })
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "mistral",
|
||||
providerMetadataKey: "mistral",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
transport: httpTransport,
|
||||
})
|
||||
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
@@ -32,17 +32,19 @@ export const PATH = "/responses"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
const OpenResponsesInputText = Schema.Struct({
|
||||
export const OpenResponsesInputText = Schema.Struct({
|
||||
type: Schema.tag("input_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
const OpenResponsesInputImage = Schema.Struct({
|
||||
export const OpenResponsesInputImage = Schema.Struct({
|
||||
type: Schema.tag("input_image"),
|
||||
image_url: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
})
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
export const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
file_data: Schema.optional(Schema.String),
|
||||
file_url: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -54,7 +56,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
|
||||
|
||||
const OpenResponsesOutputText = Schema.Struct({
|
||||
export const OpenResponsesOutputText = Schema.Struct({
|
||||
type: Schema.tag("output_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
@@ -62,6 +64,13 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
export const MessageMetadata = Schema.Struct({
|
||||
itemId: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(MessagePhase),
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
@@ -72,7 +81,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const OpenResponsesReasoningItem = Schema.Struct({
|
||||
export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
@@ -149,16 +158,30 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
export const CompactionItem = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
id: optionalNull(Schema.String),
|
||||
encrypted_content: Schema.String,
|
||||
})
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("user"),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
@@ -176,14 +199,14 @@ export const InputItem = Schema.Union([
|
||||
HostedToolItem,
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
export type ExtendedHostedToolItem = {
|
||||
export type HostedToolReplayItem = {
|
||||
readonly type: string
|
||||
readonly id: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| ExtendedHostedToolItem
|
||||
| HostedToolReplayItem
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -267,7 +290,7 @@ const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
@@ -373,7 +396,7 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
export interface Extension {
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly lowerMedia?: (input: {
|
||||
@@ -381,12 +404,14 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
|
||||
|
||||
export interface ParserState {
|
||||
readonly provider: LLMRequest["model"]["provider"]
|
||||
readonly completedCompactions: ReadonlySet<string>
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
@@ -397,6 +422,9 @@ export interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedMessages: ReadonlySet<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
@@ -482,12 +510,15 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[metadataKey(request.model)]?.detail,
|
||||
)
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
@@ -498,26 +529,31 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
detail,
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
return {
|
||||
type: "input_image" as const,
|
||||
image_url: url ?? media.dataUrl,
|
||||
detail,
|
||||
}
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
part: LLMRequest["messages"][number]["content"][number],
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
|
||||
})
|
||||
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
|
||||
const lowered = yield* lowerMedia(part, request, adapter, "message")
|
||||
if (lowered.type === "input_video")
|
||||
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
|
||||
return lowered
|
||||
})
|
||||
|
||||
@@ -526,13 +562,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
adapter,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
@@ -540,47 +576,52 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
adapter,
|
||||
)
|
||||
})
|
||||
|
||||
const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
const providerMetadataKey = metadataKey(request.model)
|
||||
|
||||
for (const message of request.messages) {
|
||||
const metadata = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
if (message.role === "system") {
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
|
||||
})
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
|
||||
if (content.length > 0)
|
||||
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -593,9 +634,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const groups = content.reduce<
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const partMetadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
|
||||
const partPhase = messagePhase(partMetadata?.phase)
|
||||
const phase = partPhase === undefined ? metadata?.phase : partPhase
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -606,6 +648,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -613,6 +656,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
flushText()
|
||||
if (part.provider !== request.model.provider || part.encrypted === undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Compaction state must be replayed to its originating provider and API",
|
||||
)
|
||||
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
content.push(part)
|
||||
continue
|
||||
@@ -646,7 +698,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
? undefined
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
: adapter.restoreHostedToolItem?.(part.result.value)
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
@@ -660,13 +712,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
|
||||
"text",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
@@ -679,11 +729,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request, extension),
|
||||
output: yield* lowerToolResultOutput(part, request, adapter),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -691,13 +741,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, adapter),
|
||||
...(instructions ? { instructions } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerGeneration = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const generation = request.generation
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
...(instructions ? { instructions } : {}),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
@@ -725,7 +792,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
export const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
@@ -735,42 +802,34 @@ const allowedToolChoice = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, extension),
|
||||
...(yield* lowerConversation(request, adapter)),
|
||||
...lowerGeneration(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(
|
||||
extension.name,
|
||||
adapter.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...lowerOptions(request),
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -780,7 +839,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
|
||||
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||
@@ -810,6 +869,8 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
|
||||
return hasFunctionCall ? "tool-calls" : "unknown"
|
||||
}
|
||||
|
||||
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
|
||||
[state.providerMetadataKey]: metadata,
|
||||
})
|
||||
@@ -953,12 +1014,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
|
||||
const phase = messagePhase(item.phase)
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
completedMessages.add(id)
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
@@ -971,6 +1036,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
@@ -1086,8 +1152,32 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "compaction") {
|
||||
if (!item.id || typeof item.encrypted_content !== "string")
|
||||
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
|
||||
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.compaction({
|
||||
provider: state.provider,
|
||||
id: item.id,
|
||||
encrypted: item.encrypted_content,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{ ...state, lifecycle, completedCompactions: new Set([...state.completedCompactions, item.id]) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
completedMessages.add(item.id)
|
||||
if (state.message !== undefined && state.message.id !== item.id)
|
||||
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
|
||||
const message = state.message
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
@@ -1100,13 +1190,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
completedMessages,
|
||||
message: undefined,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1245,26 +1335,38 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
if (item.type !== "compaction" && item.type !== "function_call") continue
|
||||
if (item.type === "compaction") {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
}
|
||||
if (
|
||||
item.type === "function_call" &&
|
||||
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
|
||||
)
|
||||
continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending = yield* ToolStream.finishAll(current.id, current.tools)
|
||||
current = {
|
||||
...current,
|
||||
tools: pending.tools,
|
||||
hasFunctionCall:
|
||||
current.hasFunctionCall ||
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
|
||||
}
|
||||
events.push(...pending.events)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
normalized: mapFinishReason(event, current.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
@@ -1276,7 +1378,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1410,16 +1512,19 @@ export const step = (state: ParserState, input: Event) => {
|
||||
* The provider-neutral Open Responses protocol. Provider-specific Responses
|
||||
* implementations compose this baseline with their own tools and event variants.
|
||||
*/
|
||||
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
|
||||
id: extension.id,
|
||||
name: extension.name,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
|
||||
provider: request.model.provider,
|
||||
completedCompactions: new Set<string>(),
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -20,6 +21,14 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = OpenResponses.PATH
|
||||
|
||||
export const ContextManagement = Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
)
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
type: Schema.tag("image_generation"),
|
||||
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
|
||||
@@ -78,6 +87,14 @@ const OpenAIResponsesCoreFields = {
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
@@ -86,11 +103,11 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const extension = {
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
@@ -125,15 +142,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return yield* decodeBody({
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
...(yield* OpenResponses.lowerConversation(request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -141,7 +157,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice:
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +221,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
@@ -223,6 +240,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import type { MediaPart } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
@@ -57,6 +57,16 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
|
||||
),
|
||||
)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
})
|
||||
|
||||
// Route by MIME. Known image/document formats lower into a typed block; anything
|
||||
// else fails with a clear error instead of silently degrading to a malformed
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
@@ -66,8 +76,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
@@ -75,8 +84,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
import { Endpoint } from "../../route/endpoint.js"
|
||||
import { RequestExecutor } from "../../route/executor.js"
|
||||
import { HttpTransport } from "../../route/transport/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
|
||||
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(Schema.Unknown),
|
||||
instructions: optionalNull(Schema.String),
|
||||
previous_response_id: optionalNull(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
prompt_cache_key: optionalNull(Schema.String),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
|
||||
const File = Schema.Union([
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_url: Schema.String,
|
||||
file_data: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_data: Schema.String,
|
||||
file_url: Schema.optional(Schema.Never),
|
||||
}),
|
||||
])
|
||||
const MessageFields = {
|
||||
type: Schema.Literal("message"),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(OpenResponses.MessagePhase),
|
||||
}
|
||||
const Response = Schema.Struct({
|
||||
object: Schema.Literal("response.compaction"),
|
||||
output: Schema.Array(
|
||||
Schema.Union([
|
||||
OpenResponses.CompactionItem,
|
||||
OpenResponses.OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
|
||||
Schema.isMinLength(1),
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Array(Text).check(Schema.isMinLength(1)),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
|
||||
})
|
||||
|
||||
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
const native = yield* OpenResponses.lowerConversation(request, adapter)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
...native,
|
||||
service_tier: request.providerOptions?.serviceTier,
|
||||
prompt_cache_key: ProviderShared.promptCacheKey(request),
|
||||
},
|
||||
request.http?.body,
|
||||
),
|
||||
)
|
||||
const url = Endpoint.render(route.endpoint, { request, body: native })
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
request: LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
}),
|
||||
body,
|
||||
endpoint: Endpoint.path(url.toString()),
|
||||
auth: route.auth,
|
||||
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
|
||||
})
|
||||
const response = yield* executor.execute(
|
||||
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
options?.http,
|
||||
)
|
||||
const text = yield* RequestExecutor.responseStream(response).pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runFold(
|
||||
() => "",
|
||||
(text, chunk) => text + chunk,
|
||||
),
|
||||
)
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: route.id,
|
||||
message,
|
||||
body: text,
|
||||
cause,
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
}),
|
||||
})
|
||||
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
|
||||
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
|
||||
)
|
||||
if (!result.output.some((item) => item.type === "compaction"))
|
||||
return yield* invalid("Compaction response did not contain a checkpoint")
|
||||
return new CompactionResponse({
|
||||
replacement: result.output.map((item) => toMessage(item, request.model)),
|
||||
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
})
|
||||
|
||||
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
|
||||
if (item.type === "compaction")
|
||||
return Message.assistant(
|
||||
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
|
||||
)
|
||||
|
||||
const key = OpenResponses.metadataKey(model)
|
||||
if (item.type === "reasoning") {
|
||||
const summary = item.summary.length ? item.summary : [{ text: "" }]
|
||||
return Message.assistant(
|
||||
summary.map((part) => ({
|
||||
type: "reasoning" as const,
|
||||
text: part.text,
|
||||
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return Message.make({
|
||||
role: item.role,
|
||||
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
|
||||
content: item.content.map((part): ContentPart => {
|
||||
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
|
||||
if (part.type === "input_image")
|
||||
return {
|
||||
type: "media",
|
||||
data: part.image_url,
|
||||
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
const data = part.file_url === undefined ? part.file_data : part.file_url
|
||||
return {
|
||||
type: "media",
|
||||
data,
|
||||
filename: part.filename,
|
||||
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export * as ResponsesCompaction from "./responses-compaction.js"
|
||||
@@ -4,6 +4,7 @@ import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
@@ -36,15 +37,19 @@ const XAIResponsesBody = Schema.Struct({
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const extension = {
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
|
||||
if (request.providerOptions?.contextManagement !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
)
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
@@ -78,10 +83,12 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export const compact = ResponsesCompaction.make(adapter)
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -9,8 +10,9 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -102,7 +102,11 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: endpoint(input, modelID),
|
||||
@@ -161,10 +165,11 @@ const config = (settings: Settings): Config => {
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
|
||||
@@ -57,7 +57,9 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: AnthropicMessages.framing,
|
||||
transport: AnthropicMessages.transport<
|
||||
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
|
||||
>(),
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as Mistral from "./mistral.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { MistralChat } from "../protocols/mistral-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("mistral")
|
||||
|
||||
export type ProviderOptions = MistralChat.ProviderOptionsInput
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export const route = MistralChat.route
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? MistralChat.DEFAULT_BASE_URL },
|
||||
auth: AuthOptions.bearer(input, "MISTRAL_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<ProviderOptions>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Mistral from "./mistral.js"
|
||||
@@ -1,10 +1,13 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ContextManagement } from "../protocols/openai-responses.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,7 +73,10 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: { baseURL: input.baseURL, query: input.queryParams },
|
||||
@@ -129,7 +132,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -32,6 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -102,7 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -7,11 +7,13 @@ import { HttpTransport } from "./transport/index.js"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
|
||||
import type { Protocol } from "./protocol.js"
|
||||
import { applyCachePolicy } from "../cache-policy.js"
|
||||
import { normalizeToolHistory } from "../tool-history.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -33,7 +35,12 @@ export interface RouteBody<Body> {
|
||||
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
|
||||
}
|
||||
|
||||
export interface Route<Body, Prepared = unknown> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
readonly provider?: ProviderID
|
||||
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||
@@ -41,13 +48,15 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly protocol: ProtocolID
|
||||
readonly endpoint: Endpoint.Definition<Body>
|
||||
readonly auth: Auth.Definition
|
||||
/** Deployment headers resolved once for every operation, before transport authentication. */
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options>
|
||||
) => LanguageModel<Options, Compact>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
@@ -65,7 +74,11 @@ export interface Route<Body, Prepared = unknown> {
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute = Route<any, any>
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
>
|
||||
|
||||
export type HttpOptionsInput = HttpOptions.Input
|
||||
|
||||
@@ -98,15 +111,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||
route: AnyRoute,
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options>({
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
route,
|
||||
@@ -149,6 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -166,24 +183,40 @@ export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
|
||||
}
|
||||
|
||||
export type CompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
}
|
||||
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const routeDefaults = request.model.route.defaults
|
||||
const modelDefaults = request.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
|
||||
return LLMRequest.update(request, {
|
||||
const messages = normalizeToolHistory(request.messages)
|
||||
const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages })
|
||||
const routeDefaults = normalized.model.route.defaults
|
||||
const modelDefaults = normalized.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation)
|
||||
return LLMRequest.update(normalized, {
|
||||
generation: generation ?? new GenerationOptions({}),
|
||||
providerOptions: mergeProviderOptions(
|
||||
routeDefaults.providerOptions,
|
||||
modelDefaults?.providerOptions,
|
||||
request.providerOptions,
|
||||
normalized.providerOptions,
|
||||
),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
|
||||
})
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -205,6 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -280,12 +314,14 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
|
||||
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
|
||||
const route: Route<Body, Prepared> = {
|
||||
compact: routeInput.compact,
|
||||
id: routeInput.id,
|
||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||
providerMetadataKey: routeInput.providerMetadataKey,
|
||||
protocol: protocol.id,
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
headers: routeInput.headers,
|
||||
transport: routeInput.transport,
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
@@ -307,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -315,7 +351,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
middleware: options?.http,
|
||||
webSocket: options?.webSocket,
|
||||
}),
|
||||
@@ -405,6 +440,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -432,6 +473,7 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
if ("transport" in input) return makeFromTransport(input)
|
||||
const protocol = input.protocol
|
||||
return makeFromTransport({
|
||||
compact: input.compact,
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
providerMetadataKey: input.providerMetadataKey,
|
||||
@@ -444,9 +486,19 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const prepareRequest = (request: LLMRequest) => {
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
|
||||
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
? resolved
|
||||
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const resolved = prepareRequest(request)
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
@@ -505,6 +557,15 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -515,16 +576,29 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.invalidRequest(
|
||||
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
)
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const Route = { make } as const
|
||||
|
||||
export const LLMClient = {
|
||||
canCompact,
|
||||
compact,
|
||||
Service,
|
||||
layer,
|
||||
stream,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/schema/llm"
|
||||
import { ContentBlockID, ToolCallID } from "./ids.js"
|
||||
import {
|
||||
Message,
|
||||
CompactionPart,
|
||||
ProviderMetadata,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
@@ -62,6 +63,8 @@ export { ProviderMetadata } from "./messages.js"
|
||||
* Matches the same escape-hatch field on `LLMEvent`.
|
||||
*/
|
||||
export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
/** Effective input size of the final message iteration, when reported; not billed totals. */
|
||||
contextTokens: Schema.optional(Schema.Number),
|
||||
inputTokens: Schema.optional(Schema.Number),
|
||||
outputTokens: Schema.optional(Schema.Number),
|
||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||
@@ -72,7 +75,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* to zero. The one place subtraction happens in this contract; the clamp
|
||||
* means a provider reporting `reasoningTokens > outputTokens` produces a
|
||||
* harmless zero rather than a negative that crashes downstream schemas.
|
||||
@@ -88,6 +91,12 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
|
||||
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
|
||||
|
||||
/** A replacement context window, not an assistant message to append to prior history. */
|
||||
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
|
||||
replacement: Schema.Array(Message),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
@@ -241,6 +250,7 @@ export const ProviderErrorEvent = Schema.Struct({
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
CompactionPart,
|
||||
StepStart,
|
||||
TextStart,
|
||||
TextDelta,
|
||||
@@ -274,6 +284,7 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
compaction: CompactionPart.make,
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
@@ -311,6 +322,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
compaction: llmEventTagged.guards.compaction,
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
@@ -333,10 +345,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
|
||||
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
const joinFragments = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
|
||||
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
@@ -563,6 +575,8 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
|
||||
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
const next = appendEvent(state, event)
|
||||
switch (event.type) {
|
||||
case "compaction":
|
||||
return appendContent(next, event)
|
||||
case "text-start":
|
||||
return ensureText(next, event.id, event.providerMetadata)
|
||||
case "text-delta":
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
HttpOptions,
|
||||
JsonSchema,
|
||||
LanguageModelSchema,
|
||||
type LanguageModel,
|
||||
ProviderOptions,
|
||||
} from "./options.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
import { ProviderID } from "./ids.js"
|
||||
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
@@ -53,14 +54,10 @@ export const MediaPart = Schema.Struct({
|
||||
filename: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
"value" in value
|
||||
|
||||
const toolResultValueSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("json"),
|
||||
@@ -80,6 +77,7 @@ const toolResultValueSchema = Schema.Union([
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" })
|
||||
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||
const isToolResultValue = Schema.is(toolResultValueSchema)
|
||||
|
||||
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||
is: isToolResultValue,
|
||||
@@ -190,9 +188,40 @@ export const ReasoningPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
/** A provider-generated context checkpoint, distinct from visible assistant text. */
|
||||
type CompactionContent =
|
||||
| { readonly encrypted: string; readonly text?: never }
|
||||
| { readonly text: string | null; readonly encrypted?: never }
|
||||
|
||||
const compactionPartSchema = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
provider: ProviderID,
|
||||
id: Schema.optional(Schema.String),
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
/** Null means the provider failed to produce a summary; prior history must be retained. */
|
||||
text: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
.pipe(
|
||||
Schema.refine(
|
||||
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
|
||||
{ message: "Compaction requires either encrypted content or a summary" },
|
||||
),
|
||||
)
|
||||
.annotate({ identifier: "LLM.Content.Compaction" })
|
||||
export type CompactionPart = typeof compactionPartSchema.Type
|
||||
export const CompactionPart = Object.assign(compactionPartSchema, {
|
||||
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
|
||||
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
|
||||
})
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
MediaPart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
ReasoningPart,
|
||||
CompactionPart,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
@@ -200,6 +229,7 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
role: MessageRole,
|
||||
content: Schema.Array(ContentPart),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -277,7 +307,7 @@ export namespace ToolChoice {
|
||||
}
|
||||
}
|
||||
|
||||
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
const requestSchema = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
model: LanguageModelSchema,
|
||||
system: Schema.Array(SystemPart),
|
||||
@@ -291,12 +321,26 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
// Stable cache affinity for protocols that support provider-managed prompt caching.
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
})
|
||||
|
||||
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
|
||||
requestSchema.fields,
|
||||
) {
|
||||
declare readonly model: Model
|
||||
|
||||
// Preserve model inference instead of inheriting the schema's erased constructor signature.
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(input: LLMRequest.Input<Model>) {
|
||||
super(input)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LLMRequest {
|
||||
export type Input = ConstructorParameters<typeof LLMRequest>[0]
|
||||
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
|
||||
readonly model: Model
|
||||
}
|
||||
|
||||
export const input = (request: LLMRequest): Input => ({
|
||||
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
|
||||
id: request.id,
|
||||
model: request.model,
|
||||
system: request.system,
|
||||
@@ -311,7 +355,16 @@ export namespace LLMRequest {
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
export const update = (request: LLMRequest, patch: Partial<Input>) => {
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest,
|
||||
patch: Partial<Input<Model>> & { readonly model: Model },
|
||||
): LLMRequest<Model>
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest<Model>,
|
||||
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
|
||||
): LLMRequest<Model>
|
||||
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
|
||||
export function update(request: LLMRequest, patch: Partial<Input>) {
|
||||
if (Object.keys(patch).length === 0) return request
|
||||
return new LLMRequest({
|
||||
...input(request),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute } from "../route/client.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -173,15 +173,18 @@ export namespace LanguageModelCompatibility {
|
||||
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
|
||||
}
|
||||
|
||||
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
|
||||
constructor(input: LanguageModel.ConstructorInput) {
|
||||
constructor(input: LanguageModel.ConstructorInput<Compact>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
@@ -189,8 +192,11 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
this.compatibility = input.compatibility
|
||||
}
|
||||
|
||||
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
|
||||
return new LanguageModel<Options>({
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
@@ -200,7 +206,9 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
@@ -210,25 +218,41 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input>,
|
||||
): LanguageModel<Options>
|
||||
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
|
||||
if (Object.keys(patch).length === 0) return model
|
||||
return LanguageModel.make<Options>({
|
||||
...LanguageModel.input(model),
|
||||
...patch,
|
||||
route: patch.route ?? model.route,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput = {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
readonly defaults?: LanguageModelDefaults.Input
|
||||
@@ -267,7 +291,7 @@ export const CachePolicyObject = Schema.Struct({
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
Schema.Struct({ tail: Schema.Natural }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
|
||||
+25
-11
@@ -4,6 +4,7 @@ import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -99,8 +100,6 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
|
||||
|
||||
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
@@ -113,26 +112,41 @@ const make = (options: LayerOptions) =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const take = (request: LLMRequest) =>
|
||||
Effect.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
const gate = activeGate
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
if (!gate) return Effect.succeed(response)
|
||||
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
|
||||
|
||||
const EMPTY_TOOL_OUTPUT = "(no tool output)"
|
||||
const MISSING_TOOL_RESULT = "Tool result missing"
|
||||
|
||||
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
const normalized: Message[] = []
|
||||
const pending = new Map<string, ToolCallPart>()
|
||||
const appendMissingResults = () => {
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user" || message.role === "assistant") appendMissingResults()
|
||||
|
||||
if (message.role === "tool") {
|
||||
const tool = normalizeToolMessage(message, pending)
|
||||
if (tool) normalized.push(tool)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized.push(message)
|
||||
if (message.role !== "assistant") continue
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
|
||||
? messages
|
||||
: normalized
|
||||
}
|
||||
|
||||
function missingToolResults(calls: Iterable<ToolCallPart>) {
|
||||
return new Message({
|
||||
role: "tool",
|
||||
content: [...calls].map((call) =>
|
||||
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPart>): Message | undefined {
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type !== "tool-result" || part.providerExecuted === true) return part
|
||||
const call = pending.get(part.id)
|
||||
if (call) pending.delete(part.id)
|
||||
return normalizeToolResult(part, call?.name ?? part.name)
|
||||
})
|
||||
if (content.length === 0) return undefined
|
||||
if (content.every((part, index) => part === message.content[index])) return message
|
||||
return new Message({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content,
|
||||
metadata: message.metadata,
|
||||
providerMetadata: message.providerMetadata,
|
||||
native: message.native,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
|
||||
const named = part.name === name ? part : { ...part, name }
|
||||
if (named.result.type === "text" && named.result.value === "")
|
||||
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (named.result.type === "error" && named.result.value === "")
|
||||
return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (named.result.type !== "content") return named
|
||||
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
|
||||
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
|
||||
if (value.length === named.result.value.length) return named
|
||||
return { ...named, result: { type: "content", value } }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route/client.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
import { runtimeLayer } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
|
||||
const calls: string[] = []
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
calls.push(path)
|
||||
const body = await request.json()
|
||||
expect(request.headers.get("authorization")).toBe("Bearer fixture")
|
||||
if (path === "/v1/responses/compact") {
|
||||
expect(body.stream).toBeUndefined()
|
||||
return Response.json({
|
||||
object: "response.compaction",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
|
||||
})
|
||||
}
|
||||
expect(body.input[0]).toEqual(checkpoint)
|
||||
expect(body.stream).toBe(true)
|
||||
if (calls.length === 2)
|
||||
return new Response(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
|
||||
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
|
||||
const output = sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_2" } },
|
||||
)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(37)))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => server.stop(true)),
|
||||
)
|
||||
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
|
||||
"fixture",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "original",
|
||||
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const messages = [...compacted.replacement, Message.user("Look up the answer")]
|
||||
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
const call = first.toolCalls[0]!
|
||||
const last = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(last.text).toBe("The answer is 42.")
|
||||
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
|
||||
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
|
||||
import { OpenAI, Anthropic } from "../src/providers.js"
|
||||
|
||||
test("runtime capability checks follow model and route updates", () => {
|
||||
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
|
||||
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
|
||||
const request = LLM.request({ model: supported, prompt: "hello" })
|
||||
expect(LLMClient.canCompact(request)).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
|
||||
).toBe(false)
|
||||
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
|
||||
})
|
||||
|
||||
test("explicit compaction serializes a replacement window without a messages alias", () => {
|
||||
const response = new CompactionResponse({
|
||||
replacement: [
|
||||
Message.user("retained input"),
|
||||
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
|
||||
],
|
||||
})
|
||||
const codec = Schema.fromJsonString(CompactionResponse)
|
||||
const decoded = Schema.decodeSync(codec)(Schema.encodeSync(codec)(response))
|
||||
expect(decoded.replacement).toEqual(response.replacement)
|
||||
expect("messages" in decoded).toBe(false)
|
||||
})
|
||||
|
||||
test("compaction survives event assembly and message serialization without becoming text", () => {
|
||||
const part = CompactionPart.make({
|
||||
provider: ProviderID.make("openai"),
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "before" }),
|
||||
LLMEvent.textDelta({ id: "before", text: "Before" }),
|
||||
LLMEvent.textEnd({ id: "before" }),
|
||||
part,
|
||||
LLMEvent.textStart({ id: "after" }),
|
||||
LLMEvent.textDelta({ id: "after", text: "After" }),
|
||||
LLMEvent.textEnd({ id: "after" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])!
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
|
||||
expect(response.text).toBe("BeforeAfter")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
|
||||
})
|
||||
|
||||
test("compaction requires exactly one typed representation", () => {
|
||||
const provider = ProviderID.make("anthropic")
|
||||
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
|
||||
const decode = Schema.decodeUnknownSync(CompactionPart)
|
||||
expect(() => decode({ type: "compaction", provider })).toThrow()
|
||||
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
|
||||
})
|
||||
|
||||
test("tagged content and event guards accept both checkpoint representations", () => {
|
||||
for (const part of [
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
|
||||
]) {
|
||||
expect(LLMEvent.is.compaction(part)).toBe(true)
|
||||
expect(LLMEvent.guards.compaction(part)).toBe(true)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Message.assistant(part)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
|
||||
import { Auth, LLMClient } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
@@ -77,6 +77,55 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the last tool definition for duplicate names", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Use a tool.",
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "search", description: "search", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: { type: "object" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "search", description: "search", parameters: { type: "object" }, strict: false },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes tool history before protocol lowering", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
|
||||
messages: [
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] },
|
||||
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing" },
|
||||
{ role: "user", content: "Continue." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "zai-glm-5-2",
|
||||
"tags": [
|
||||
"prefix:mistral-chat-glm",
|
||||
"provider:mistral",
|
||||
"protocol:mistral-chat",
|
||||
"hosted-model",
|
||||
"tool",
|
||||
"tool-call"
|
||||
],
|
||||
"name": "mistral-chat-glm/streams-an-indexed-tool-call",
|
||||
"recordedAt": "2026-08-30T17:38:02.921Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"zai-glm-5-2\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":256,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-8cc4d8f9f07b298a\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"Paris\\\"}\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"index\":0,\"content\":\"\"},\"finish_reason\":\"stop\",\"logprobs\":null}],\"usage\":{\"prompt_tokens\":171,\"total_tokens\":182,\"completion_tokens\":11,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
|
||||
"name": "mistral-chat/drives-a-tool-loop",
|
||||
"recordedAt": "2026-08-30T17:18:49.552Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+47
File diff suppressed because one or more lines are too long
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "mistral-small-latest",
|
||||
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "text", "usage"],
|
||||
"name": "mistral-chat/streams-text-with-usage",
|
||||
"recordedAt": "2026-08-30T17:18:45.432Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.mistral.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"max_tokens\":40,\"temperature\":0,\"reasoning_effort\":\"none\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"total_tokens\":24,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionPart,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
} from "../../src/index.js"
|
||||
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers.js"
|
||||
|
||||
const openai = OpenAI.configure({
|
||||
apiKey: "test",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
}).responses("gpt-5.3-codex")
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure().responses("fixture"),
|
||||
Azure.configure({ resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
OpenAI.model("fixture", {}),
|
||||
Azure.responsesModel("fixture", { resourceName: "test" }),
|
||||
XAI.model("fixture", {}),
|
||||
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
LanguageModel.make(LanguageModel.input(openai)),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
|
||||
}
|
||||
|
||||
const unsupported = {
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
|
||||
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Anthropic has no standalone compact endpoint.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.openaiChat)
|
||||
// @ts-expect-error Azure Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.azureChat)
|
||||
// @ts-expect-error xAI Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.xaiChat)
|
||||
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
LLMClient.Service.use((client) => {
|
||||
// @ts-expect-error The service enforces the same capability as the convenience function.
|
||||
return client.compact(unsupported.anthropic)
|
||||
})
|
||||
|
||||
const request = LLM.request({ model: openai, prompt: "hello" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
|
||||
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
|
||||
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
|
||||
LLMClient.compact(switched)
|
||||
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
|
||||
LLMClient.compact(
|
||||
// @ts-expect-error Replacing the route also replaces compaction capability.
|
||||
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
|
||||
)
|
||||
|
||||
declare const dynamicModel: LanguageModel
|
||||
declare const dynamicPatch: Partial<LLMRequest.Input>
|
||||
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
|
||||
// @ts-expect-error A dynamically selected model must be narrowed first.
|
||||
LLMClient.compact(dynamicRequest)
|
||||
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
|
||||
// @ts-expect-error An optional model override cannot retain the old capability statically.
|
||||
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
|
||||
|
||||
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
|
||||
const provider = ProviderID.make("anthropic")
|
||||
CompactionPart.make({ provider, text: "summary" })
|
||||
CompactionPart.make({ provider, text: null })
|
||||
// @ts-expect-error A checkpoint must have a representation.
|
||||
CompactionPart.make({ provider })
|
||||
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
|
||||
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
|
||||
// @ts-expect-error A failed summary cannot also carry encrypted content.
|
||||
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
|
||||
// @ts-expect-error The canonical message type also enforces the invariant.
|
||||
Message.assistant({ type: "compaction", provider })
|
||||
if (checkpoint.encrypted !== undefined) {
|
||||
checkpoint.encrypted satisfies string
|
||||
checkpoint.text satisfies undefined
|
||||
}
|
||||
if (checkpoint.text !== undefined) {
|
||||
checkpoint.text satisfies string | null
|
||||
checkpoint.encrypted satisfies undefined
|
||||
}
|
||||
checkpoint.encrypted
|
||||
// @ts-expect-error Compaction parts do not contain a generic provider payload.
|
||||
checkpoint.value
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
|
||||
Effect.map((result) => {
|
||||
result.replacement satisfies ReadonlyArray<Message>
|
||||
// @ts-expect-error The replacement window is named explicitly; the old field is not an alias.
|
||||
result.messages
|
||||
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
|
||||
result.message
|
||||
}),
|
||||
)
|
||||
LLM.request({
|
||||
model: openai,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A token threshold is numeric.
|
||||
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
|
||||
},
|
||||
})
|
||||
const anthropic = Anthropic.configure().model("claude-opus-4-6")
|
||||
LLM.request({
|
||||
model: anthropic,
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model: anthropic,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A pause setting is boolean.
|
||||
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers.js"
|
||||
|
||||
const selected = Mistral.provider.model("mistral-small-latest")
|
||||
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } })
|
||||
|
||||
LLM.request({
|
||||
model: selected,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Mistral reasoning effort must be a string.
|
||||
providerOptions: { reasoningEffort: 1 },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model: selected,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Mistral prompt mode only supports reasoning.
|
||||
providerOptions: { promptMode: "standard" },
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "empty iterations fall back to top-level usage",
|
||||
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
|
||||
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "compaction-only usage has no post-compaction context size",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
|
||||
},
|
||||
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "partially reported iterations preserve known totals",
|
||||
usage: {
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
|
||||
{ type: "message", output_tokens: 3 },
|
||||
],
|
||||
},
|
||||
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "missing counters remain unknown rather than zero",
|
||||
usage: { iterations: [{ type: "message" }] },
|
||||
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
|
||||
},
|
||||
]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: fixture.usage } },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
).effect(fixture.name, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
prompt: "hello",
|
||||
}),
|
||||
)
|
||||
expect(result.usage).toMatchObject(fixture.expected)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
|
||||
]) {
|
||||
for (const summary of ["Summary of the conversation", null]) {
|
||||
const block = { type: "compaction", content: summary }
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
|
||||
if (body.messages.length === 1) {
|
||||
expect(body.context_management.edits).toEqual([
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pause_after_compaction: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (body.messages.length > 1) {
|
||||
expect(body.messages[1].content).toEqual([block])
|
||||
expect(body.context_management).toBeUndefined()
|
||||
}
|
||||
return respond(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "compaction" },
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 5,
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
|
||||
{ type: "message", input_tokens: 1000, output_tokens: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect(
|
||||
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
http: { headers: { "anthropic-beta": "existing-beta" } },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pauseAfterCompaction: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
|
||||
expect(first.text).toBe("")
|
||||
expect(first.usage?.inputTokens).toBe(51010)
|
||||
expect(first.usage?.outputTokens).toBe(1005)
|
||||
expect(first.usage?.totalTokens).toBe(52015)
|
||||
expect(first.usage?.contextTokens).toBe(1000)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {},
|
||||
messages: [...request.messages, message, Message.user("continue")],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const events of [
|
||||
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
|
||||
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
|
||||
[
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "message_stop" },
|
||||
],
|
||||
]) {
|
||||
testEffect(fixedResponse(sseEvents(...events))).effect(
|
||||
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -74,6 +74,48 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty system text while preserving whitespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: "" }] }))
|
||||
const whitespace = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: " " }] }))
|
||||
|
||||
expect(empty.body.system).toBeUndefined()
|
||||
expect(whitespace.body.system).toEqual([{ type: "text", text: " " }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters whitespace-only text and removes empty messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user(" \n\t"),
|
||||
Message.user([]),
|
||||
Message.user([
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: " Keep this spacing. " },
|
||||
{ type: "text", text: " \n\t" },
|
||||
]),
|
||||
Message.assistant(" \n\t"),
|
||||
Message.assistant([]),
|
||||
Message.assistant([{ type: "reasoning", text: "" }]),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "" },
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: " Keep this spacing. " }] },
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -903,6 +945,54 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a reasoning signature when message_stop closes the block", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = Route.make({
|
||||
id: "custom-anthropic-messages",
|
||||
provider: "custom-anthropic",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
framing: AnthropicMessages.framing,
|
||||
}).model({ id: "custom-model" })
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Reasoning." } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Think." })).pipe(
|
||||
Effect.provide(fixedResponse(body)),
|
||||
)
|
||||
|
||||
const reasoningEnds = response.events.filter((event) => event.type === "reasoning-end")
|
||||
expect(reasoningEnds).toHaveLength(1)
|
||||
expect(reasoningEnds[0]).toMatchObject({
|
||||
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Reasoning.",
|
||||
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "Reasoning.", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -936,6 +1026,7 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Hello!" },
|
||||
@@ -949,6 +1040,41 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal state across usage-only message deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "X" },
|
||||
usage: { output_tokens: 8 },
|
||||
},
|
||||
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
||||
providerMetadata: { anthropic: { stopSequence: "X" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires message_stop before completing a streamed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import {
|
||||
CacheHint,
|
||||
GenerationOptions,
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
@@ -83,6 +84,17 @@ const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>)
|
||||
const fixedBytes = (bytes: Uint8Array) =>
|
||||
fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
|
||||
|
||||
const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
|
||||
fixedResponse(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
chunks.forEach((chunk) => controller.enqueue(chunk))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
|
||||
)
|
||||
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
@@ -113,6 +125,50 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty initial system blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* compileRequest(LLM.request({ model, system: "", prompt: "hello" }))
|
||||
const cachedEmpty = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "hello",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(empty.body.system).toBeUndefined()
|
||||
expect(cachedEmpty.body.system).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty system blocks while preserving order and cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "", cache },
|
||||
{ type: "text", text: "First." },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Second.", cache },
|
||||
],
|
||||
prompt: "hello",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.system).toEqual([
|
||||
{ text: "First." },
|
||||
{ text: " " },
|
||||
{ text: "Second." },
|
||||
{ cachePoint: { type: "default" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -255,6 +311,79 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
path: "file.ts",
|
||||
edits: [
|
||||
{ oldText: "a", newText: "b", "": "" },
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
"text",
|
||||
["kept", { "": false, nested: { "": null, value: "ok" } }],
|
||||
],
|
||||
nested: { "": "drop", empty: {}, onlyEmpty: { "": 1 } },
|
||||
" ": "preserve whitespace key",
|
||||
"": "drop",
|
||||
}
|
||||
const original = structuredClone(input)
|
||||
const call = ToolCallPart.make({ id: "tool_1", name: "edit", input })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
toolUse: {
|
||||
toolUseId: "tool_1",
|
||||
name: "edit",
|
||||
input: {
|
||||
path: "file.ts",
|
||||
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
|
||||
nested: { empty: {}, onlyEmpty: {} },
|
||||
" ": "preserve whitespace key",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(input).toEqual(original)
|
||||
expect(call.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps empty tool inputs and empties inputs containing only empty keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_empty_key", name: "first", input: { "": { value: true } } }),
|
||||
ToolCallPart.make({ id: "tool_empty_object", name: "second", input: {} }),
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
|
||||
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -385,19 +514,77 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
||||
it.effect("rejects truncated event-stream frames after message stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["model_context_window_exceeded", "length"],
|
||||
["malformed_model_output", "error"],
|
||||
["malformed_tool_use", "error"],
|
||||
] as const
|
||||
const partialFrames = [
|
||||
eventFrame("metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }).subarray(0, 3),
|
||||
exceptionFrame("modelStreamErrorException", { originalMessage: "Upstream model failed" }).subarray(0, -1),
|
||||
]
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
||||
for (const partial of partialFrames) {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(concat([eventFrame("messageStop", { stopReason: "end_turn" }), partial]))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
|
||||
message: `Incomplete Bedrock Converse event-stream frame: ${partial.length} buffered bytes remain at end of stream`,
|
||||
})
|
||||
expect(error.reason.body).toBe(Encoding.encodeBase64(partial))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes frames split across transport chunks through exact-boundary EOF", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedByteChunks(body.subarray(0, 2), body.subarray(2, 17), body.subarray(17))),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps model context window exhaustion to length", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "model_context_window_exceeded" }]))),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({
|
||||
normalized: "length",
|
||||
raw: "model_context_window_exceeded",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails malformed output stop reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const reason of ["malformed_model_output", "malformed_tool_use"] as const) {
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(baseRequest).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: reason }]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: `Bedrock Converse stopped with ${reason}`,
|
||||
})
|
||||
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
|
||||
headers: { ":event-type": { value: "messageStop" } },
|
||||
body: JSON.stringify({ stopReason: reason }),
|
||||
})
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "finish")).toBeFalse()
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -444,10 +631,45 @@ describe("Bedrock Converse route", () => {
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains metadata usage that arrives before messageStop", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
http: {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -853,7 +1075,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
// Bedrock represents redactedContent blobs as base64 strings on its JSON
|
||||
// wire. The provider owns the payload and requires byte-exact replay.
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const redactedData = "AQID"
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
@@ -863,10 +1085,8 @@ describe("Bedrock Converse route", () => {
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
@@ -882,12 +1102,17 @@ describe("Bedrock Converse route", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
@@ -918,6 +1143,73 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps redacted reasoning accumulation separate by content block index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "Ag==" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 2 }],
|
||||
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "Aw==" } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "BA==" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 7 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQI=" } } },
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AwQ=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves split redacted reasoning when contentBlockStop is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid redacted reasoning base64 with the triggering event", () =>
|
||||
Effect.gen(function* () {
|
||||
const payload = { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "%%==" } } }
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(eventStreamBody(["contentBlockDelta", payload]))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
|
||||
})
|
||||
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
|
||||
headers: { ":event-type": { value: "contentBlockDelta" } },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown normal stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
@@ -1172,6 +1464,20 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects image media that is not valid base64", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1295,6 +1601,37 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects remote media URLs in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.test/report.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, WebSocketTransport } from "../../src/route.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
|
||||
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
|
||||
"WebSocket responses preserve compaction options and replay state",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
|
||||
const sent: unknown[] = []
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(message)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.stream).toBeUndefined()
|
||||
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
|
||||
sent.push(body)
|
||||
}),
|
||||
messages: Stream.fromIterable(
|
||||
[
|
||||
{ type: "response.created", response: { id: "resp_ws" } },
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
|
||||
].map((event) => JSON.stringify(event)),
|
||||
),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket })
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
|
||||
{ webSocket },
|
||||
)
|
||||
expect(sent).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const history = [
|
||||
Message.user("Remember the project codename COPPER-ORBIT-42."),
|
||||
Message.assistant(
|
||||
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
|
||||
),
|
||||
]
|
||||
|
||||
for (const provider of [
|
||||
{
|
||||
id: "openai",
|
||||
key: "OPENAI_API_KEY",
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
key: "XAI_API_KEY",
|
||||
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
|
||||
},
|
||||
]) {
|
||||
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
|
||||
"compacts and continues with the provider checkpoint",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...compacted.replacement,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
}
|
||||
|
||||
recordedTests({
|
||||
prefix: "anthropic-compaction",
|
||||
provider: "anthropic",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
|
||||
}).effect(
|
||||
"automatically compacts and continues after a pause",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user(
|
||||
"Remember the project codename COPPER-ORBIT-42. " +
|
||||
"The implementation and tests were reviewed. ".repeat(10000),
|
||||
),
|
||||
],
|
||||
generation: { maxTokens: 4096 },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const response = sseEvents(
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
|
||||
},
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.store).toBe(false)
|
||||
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
|
||||
return respond(response, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
expect(first.text).toBe("")
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
|
||||
)
|
||||
const rejected = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
providerOptions: {},
|
||||
messages: [message],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(rejected.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
|
||||
"recovers compaction from the terminal output when item completion is absent",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
)
|
||||
expect(result.message.content).toHaveLength(1)
|
||||
expect(result.message.content[0]?.type).toBe("compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
|
||||
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("cmp_bad")
|
||||
}),
|
||||
)
|
||||
|
||||
const textItem = {
|
||||
type: "message",
|
||||
id: "msg_after",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "After checkpoint" }],
|
||||
}
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
|
||||
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
|
||||
{ type: "response.output_item.added", output_index: 1, item: textItem },
|
||||
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
|
||||
{ type: "response.output_item.done", output_index: 1, item: textItem },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
|
||||
),
|
||||
),
|
||||
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
if (completed) {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
|
||||
return
|
||||
}
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Cannot recover a compaction checkpoint")
|
||||
expect(error.reason.body).toContain("response.completed")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
|
||||
}),
|
||||
),
|
||||
).effect("rejects terminal checkpoints missing an id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("missing its id")
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* OpenResponses.lowerConversation(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
system: "Keep the context",
|
||||
messages: [Message.user("hello"), Message.assistant("hi")],
|
||||
generation: { maxTokens: 100, temperature: 0.5 },
|
||||
providerOptions: { store: false },
|
||||
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
|
||||
}),
|
||||
{ id: "open-responses", name: "Open Responses" },
|
||||
)
|
||||
expect(body).toEqual({
|
||||
model: "fixture",
|
||||
instructions: "Keep the context",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -62,6 +62,62 @@ describe("provider error retention", () => {
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
|
||||
trace: { opaque: "outer" },
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(body), {
|
||||
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("RESOURCE_EXHAUSTED")
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a malformed non-record Gemini error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects and retains an explicit null Gemini error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains malformed provider frames and the original decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, Route } from "../../src/route/client.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const retained = {
|
||||
type: "message",
|
||||
role: "user",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
content: [{ type: "input_text", text: "retained" }],
|
||||
}
|
||||
const output = [retained, checkpoint]
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers["x-deployment"]).toBe("fixture")
|
||||
expect(request.headers["x-override"]).toBe("request")
|
||||
expect(request.headers["x-default"]).toBe("configured")
|
||||
expect(request.headers.authorization).toBe("Bearer test")
|
||||
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
if (new URL(request.url).pathname.endsWith("/compact")) {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "overlaid",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output }))
|
||||
}
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: string[] = []
|
||||
const middleware: string[] = []
|
||||
const route = Route.make({
|
||||
id: "compaction-headers",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
|
||||
baseURL: "https://example.com",
|
||||
query: { "api-version": "fixture" },
|
||||
}),
|
||||
auth: Auth.bearer("test"),
|
||||
headers: ({ request }) => {
|
||||
expect(request.providerOptions?.store).toBe(false)
|
||||
headers.push(String(request.model.id))
|
||||
return { "x-deployment": "fixture", "x-override": "route" }
|
||||
},
|
||||
defaults: {
|
||||
headers: { "x-default": "configured", "x-override": "configured" },
|
||||
providerOptions: { store: false },
|
||||
http: { body: { instructions: "default instructions" } },
|
||||
},
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
prompt: "hello",
|
||||
system: "system instructions",
|
||||
http: {
|
||||
headers: { "x-override": "request" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
model: "overlaid",
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const options: Parameters<typeof LLMClient.compact>[1] = {
|
||||
http: (request, next) => {
|
||||
middleware.push(new URL(request.url).pathname)
|
||||
return next(request)
|
||||
},
|
||||
}
|
||||
yield* LLMClient.generate(request, options)
|
||||
yield* LLMClient.compact(request, options)
|
||||
expect(headers).toEqual(["fixture", "fixture"])
|
||||
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const item = {
|
||||
type: model.provider === "xai" ? "x_search_call" : "computer_call",
|
||||
id: "hosted_1",
|
||||
status: "completed",
|
||||
}
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
|
||||
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Keep the context",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
|
||||
}),
|
||||
],
|
||||
})
|
||||
for (const candidate of [
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
|
||||
],
|
||||
}),
|
||||
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
const response = yield* LLMClient.compact(candidate)
|
||||
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const retainedItems = [
|
||||
retained,
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
content: [
|
||||
{ type: "output_text", text: "First" },
|
||||
{ type: "output_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Thinking" },
|
||||
{ type: "summary_text", text: "More thinking" },
|
||||
],
|
||||
encrypted_content: "reasoning-state",
|
||||
},
|
||||
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_media",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
|
||||
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
|
||||
],
|
||||
},
|
||||
checkpoint,
|
||||
]
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
...[undefined, "custom"].map((providerMetadataKey) =>
|
||||
Route.make({
|
||||
id: providerMetadataKey ?? "default-metadata",
|
||||
provider: "openai",
|
||||
providerMetadataKey,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
}).model({ id: "fixture" }),
|
||||
),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
|
||||
expect(JSON.parse(text).input).toEqual(retainedItems)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.replacement.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(compacted.replacement[1]?.content).toEqual([
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
])
|
||||
expect(compacted.replacement[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
|
||||
expect(compacted.replacement[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "fixture",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
service_tier: overlay?.service_tier ?? "flex",
|
||||
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
prompt: "hello",
|
||||
promptCacheKey: "affinity",
|
||||
providerOptions: { serviceTier: "flex" },
|
||||
generation: { maxTokens: 100 },
|
||||
http: {
|
||||
body: {
|
||||
stream: true,
|
||||
store: false,
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
...overlay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of [
|
||||
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [] },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "both.pdf",
|
||||
file_url: "https://example.com/report.pdf",
|
||||
file_data: "data:application/pdf;base64,cGRm",
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
|
||||
`rejects unsupported compact output: ${JSON.stringify(item)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain(JSON.stringify(item))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const images = [undefined, "low", "high", "auto"].map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
}))
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output: [{ type: "message", role: "user", content: images }, checkpoint],
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(text).input[0].content).toEqual(images)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLMRequest.update(
|
||||
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
|
||||
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
|
||||
)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("LLMClient.compact")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
|
||||
model.provider === "azure" ? "test" : "Bearer test",
|
||||
)
|
||||
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
|
||||
expect(body).toEqual({
|
||||
model: model.id,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
|
||||
instructions: "system",
|
||||
})
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output,
|
||||
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}
|
||||
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
|
||||
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.usage?.totalTokens).toBe(1010)
|
||||
expect(compacted.replacement.map((message) => message.role)).toEqual(["user", "assistant"])
|
||||
expect(compacted.replacement[0]?.content).toEqual([{ type: "text", text: "retained" }])
|
||||
expect(compacted.replacement[1]?.content).toEqual([
|
||||
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
|
||||
])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
OpenAICompatibleResponses.configure({ apiKey: "test", baseURL: "https://compatible.example/v1" }).model("model"),
|
||||
]) {
|
||||
testEffect(fixedResponse("must not execute")).effect(
|
||||
`${model.route.id} does not inherit an unsupported compact endpoint`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// @ts-expect-error Untyped callers must still receive the runtime capability error.
|
||||
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
|
||||
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("original payload")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
@@ -906,6 +906,94 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns unique ids to separated reasoning blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ text: "A", thought: true, thoughtSignature: "reasoning_sig_a" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "X", thoughtSignature: "text_sig_x" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ text: "B", thought: true, thoughtSignature: "reasoning_sig_b" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Y", thoughtSignature: "text_sig_y" }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
const starts = response.events.filter((event) => event.type === "reasoning-start")
|
||||
const deltas = response.events.filter((event) => event.type === "reasoning-delta")
|
||||
const ends = response.events.filter((event) => event.type === "reasoning-end")
|
||||
|
||||
expect(starts.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
|
||||
expect(starts[0]?.id).not.toBe(starts[1]?.id)
|
||||
expect(deltas.map((event) => ({ id: event.id, text: event.text }))).toEqual([
|
||||
{ id: "reasoning-0", text: "A" },
|
||||
{ id: "reasoning-1", text: "B" },
|
||||
])
|
||||
expect(ends.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
|
||||
expect(response.events.filter((event) => event.type === "text-start").map((event) => event.id)).toEqual([
|
||||
"text-0",
|
||||
"text-1",
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "text-delta").map((event) => event.id)).toEqual([
|
||||
"text-0",
|
||||
"text-1",
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "text-end").map((event) => event.id)).toEqual([
|
||||
"text-0",
|
||||
"text-1",
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "A",
|
||||
providerMetadata: { google: { thoughtSignature: "reasoning_sig_a" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "X",
|
||||
providerMetadata: { google: { thoughtSignature: "text_sig_x" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "B",
|
||||
providerMetadata: { google: { thoughtSignature: "reasoning_sig_b" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Y",
|
||||
providerMetadata: { google: { thoughtSignature: "text_sig_y" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown response parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1280,6 +1368,56 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("separates text blocks around streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "before" },
|
||||
{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } },
|
||||
{ text: "after" },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.slice(1, 8)).toEqual([
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "before" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "text-start", id: "text-1" },
|
||||
{ type: "text-delta", id: "text-1", text: "after" },
|
||||
{ type: "text-end", id: "text-1" },
|
||||
])
|
||||
const textStarts = response.events.filter((event) => event.type === "text-start")
|
||||
expect(textStarts[0]?.id).not.toBe(textStarts[1]?.id)
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "before" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "text", text: "after" },
|
||||
])
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults omitted function call args to an empty object", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [undefined, "low", "high", "auto"]
|
||||
const message = Message.user(
|
||||
details.map((detail) => ({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata:
|
||||
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
|
||||
})),
|
||||
)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input[0].content).toEqual(
|
||||
details.map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
detail,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects malformed image detail instead of silently discarding it", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata: { openai: { detail: 42 } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,694 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest")
|
||||
const request = LLM.request({ model, prompt: "Hello" })
|
||||
const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({
|
||||
choices: [{ delta, finish_reason: finishReason }],
|
||||
usage,
|
||||
})
|
||||
|
||||
describe("Mistral Chat", () => {
|
||||
test("exposes native provider and protocol identities", async () => {
|
||||
const entrypoint = await import("@opencode-ai/ai/providers/mistral")
|
||||
|
||||
expect(Mistral.id).toBe("mistral")
|
||||
expect(MistralChat.protocol.id).toBe("mistral-chat")
|
||||
expect(Mistral.route).toMatchObject({
|
||||
id: "mistral-chat",
|
||||
provider: "mistral",
|
||||
providerMetadataKey: "mistral",
|
||||
protocol: "mistral-chat",
|
||||
})
|
||||
expect(Mistral.route.endpoint).toMatchObject({
|
||||
baseURL: "https://api.mistral.ai/v1",
|
||||
path: "/chat/completions",
|
||||
})
|
||||
expect(entrypoint.model).toBeFunction()
|
||||
})
|
||||
|
||||
it.effect("lowers native messages, media, tool choice, options, and replay IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "Initial",
|
||||
messages: [
|
||||
Message.system("Updated"),
|
||||
Message.user([
|
||||
{ type: "text", text: "Inspect" },
|
||||
{ type: "media", mediaType: "image/png", data: "aW1hZ2U=" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "cGRm" },
|
||||
]),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Calling" },
|
||||
{ type: "tool-call", id: "call.same-prefix-1", name: "lookup", input: { city: "Paris" } },
|
||||
{ type: "tool-call", id: "call.same-prefix-2", name: "other", input: {} },
|
||||
]),
|
||||
Message.tool({ id: "call.same-prefix-1", name: "lookup", result: { ok: true } }),
|
||||
],
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
|
||||
],
|
||||
toolChoice: "lookup",
|
||||
promptCacheKey: "session-1",
|
||||
generation: {
|
||||
maxTokens: 64,
|
||||
seed: 7,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
stop: ["done"],
|
||||
},
|
||||
providerOptions: {
|
||||
safePrompt: true,
|
||||
documentImageLimit: 3,
|
||||
documentPageLimit: 8,
|
||||
parallelToolCalls: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "mistral-large-latest",
|
||||
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
|
||||
tool_choice: { type: "function", function: { name: "lookup" } },
|
||||
stream: true,
|
||||
max_tokens: 64,
|
||||
random_seed: 7,
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.3,
|
||||
stop: ["done"],
|
||||
prompt_cache_key: "session-1",
|
||||
safe_prompt: true,
|
||||
document_image_limit: 3,
|
||||
document_page_limit: 8,
|
||||
parallel_tool_calls: false,
|
||||
reasoning_effort: "high",
|
||||
})
|
||||
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
|
||||
{ role: "system", content: "Initial" },
|
||||
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,aW1hZ2U=" },
|
||||
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "ThinkCalling",
|
||||
},
|
||||
])
|
||||
const assistant = prepared.body.messages[3]
|
||||
const toolResult = prepared.body.messages[4]
|
||||
expect(assistant?.role).toBe("assistant")
|
||||
expect(toolResult?.role).toBe("tool")
|
||||
if (assistant?.role !== "assistant" || toolResult?.role !== "tool") return
|
||||
const ids = assistant.tool_calls?.map((tool) => tool.id) ?? []
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(ids[1]).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
expect(toolResult.tool_call_id).toBe(ids[0])
|
||||
expect(toolResult.name).toBe("lookup")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid replay IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
|
||||
Message.tool({ id: "Ab12Cd34E", name: "lookup", result: "ok" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ tool_calls: [{ id: "Ab12Cd34E" }] },
|
||||
{ tool_call_id: "Ab12Cd34E" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
promptCacheKey: "common-key",
|
||||
messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")],
|
||||
providerOptions: { promptCacheKey: "native-key", promptMode: "reasoning" },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.prompt_cache_key).toBe("native-key")
|
||||
expect(prepared.body.prompt_mode).toBe("reasoning")
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Earlier" },
|
||||
{ role: "user", content: "Continue" },
|
||||
{ role: "assistant", content: "Prefix", prefix: true },
|
||||
])
|
||||
|
||||
const uncached = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "common-key",
|
||||
cache: "none",
|
||||
providerOptions: { promptCacheKey: "native-key" },
|
||||
}),
|
||||
)
|
||||
expect(uncached.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const longKey = "cache-key-".repeat(10)
|
||||
const unbounded = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: longKey,
|
||||
}),
|
||||
)
|
||||
expect(unbounded.body.prompt_cache_key).toBe(longKey)
|
||||
|
||||
const conflict = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { reasoningEffort: "high", promptMode: "reasoning" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(conflict.message).toContain("mutually exclusive")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty assistant history unless it carries a tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant(" \n "),
|
||||
Message.assistant({ type: "reasoning", text: "\t" }),
|
||||
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves remote media URLs and structured tool-result media", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://assets.example.test/input.png",
|
||||
}),
|
||||
Message.tool({
|
||||
id: "Ab12Cd34E",
|
||||
name: "inspect",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "Result" },
|
||||
{ type: "file", mime: "image/jpeg", uri: "https://assets.example.test/output.jpg" },
|
||||
{ type: "file", mime: "application/pdf", uri: "cGRm" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: "https://assets.example.test/input.png" }],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "Ab12Cd34E",
|
||||
name: "inspect",
|
||||
content: [
|
||||
{ type: "text", text: "Result" },
|
||||
{ type: "image_url", image_url: "https://assets.example.test/output.jpg" },
|
||||
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concatenates text-only user and tool content without separators", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
]),
|
||||
Message.tool({
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "third" },
|
||||
{ type: "text", text: "fourth" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "firstsecond" },
|
||||
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "thirdfourth" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams ordered thinking and text and replays native thinking metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
|
||||
chunk({ content: [{ type: "text", text: "Answer" }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Consider",
|
||||
providerMetadata: {
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
marker: "empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
])
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
marker: "empty",
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
prefix: true,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays metadata-only native thinking", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(chunk({ content: [{ type: "thinking", thinking: [], marker: "opaque" }] }), chunk({}, "stop")),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: {
|
||||
mistral: { thinking: { type: "thinking", thinking: [], marker: "opaque" } },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: [], marker: "opaque" }],
|
||||
prefix: true,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges indexed argument fragments with missing continuation identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
|
||||
}),
|
||||
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: '"Paris"}' } }] }),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "tool-call",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
})
|
||||
expect(
|
||||
response.events.filter(
|
||||
(event) =>
|
||||
LLMEvent.is.toolInputStart(event) ||
|
||||
LLMEvent.is.toolInputDelta(event) ||
|
||||
LLMEvent.is.toolInputEnd(event) ||
|
||||
LLMEvent.is.toolCall(event),
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "tool-input-start", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '{"city":',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '"Paris"}',
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes stop to tool calls when a hosted model emits indexed tool fragments", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "chatcmpl-tool-8cc4d8f9f07b298a",
|
||||
function: { name: "lookup", arguments: '{"city":"' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: 'Paris"}' } }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "stop" })
|
||||
expect(response.toolCalls).toMatchObject([{ name: "lookup", input: { city: "Paris" } }])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates a stable ID when the first indexed fragment has null identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: null, function: { name: "lookup", arguments: { city: "Paris" } } }],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const call = response.message.content.find((part) => part.type === "tool-call")
|
||||
expect(call?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(call).toMatchObject({ name: "lookup", input: { city: "Paris" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates distinct IDs for parallel null and literal-null identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, id: null, function: { name: "first", arguments: {} } },
|
||||
{ index: 1, id: "null", function: { name: "second", arguments: {} } },
|
||||
],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const calls = response.message.content.filter((part) => part.type === "tool-call")
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(calls[1]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
|
||||
expect(calls[0]?.id).not.toBe(calls[1]?.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps parallel indexed calls independent", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, id: "Ab12Cd34E", function: { name: "first", arguments: '{"n":' } },
|
||||
{ index: 1, id: "Fg56Hi78J", function: { name: "second", arguments: '{"n":' } },
|
||||
],
|
||||
}),
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ index: 0, function: { arguments: "1}" } },
|
||||
{ index: 1, function: { arguments: "2}" } },
|
||||
],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content.filter((part) => part.type === "tool-call")).toEqual([
|
||||
{ type: "tool-call", id: "Ab12Cd34E", name: "first", input: { n: 1 } },
|
||||
{ type: "tool-call", id: "Fg56Hi78J", name: "second", input: { n: 2 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("correlates parallel identity-less fragments by batch position", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [
|
||||
{ function: { name: "first", arguments: '{"n":' } },
|
||||
{ function: { name: "second", arguments: '{"n":' } },
|
||||
],
|
||||
}),
|
||||
chunk({
|
||||
tool_calls: [{ function: { arguments: "1}" } }, { function: { arguments: "2}" } }],
|
||||
}),
|
||||
chunk({}, "tool_calls"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content.filter((part) => part.type === "tool-call")).toMatchObject([
|
||||
{ name: "first", input: { n: 1 } },
|
||||
{ name: "second", input: { n: 2 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps usage variants and clamps cache reads", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const usage of [
|
||||
{ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, num_cached_tokens: 9 },
|
||||
{ prompt_tokens: 5, completion_tokens: 2, prompt_token_details: { cached_tokens: 2 } },
|
||||
{ prompt_tokens: 5, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 3 } },
|
||||
]) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({}, "stop", usage)))),
|
||||
)
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.usage?.cacheReadInputTokens).toBe(
|
||||
Math.min(
|
||||
5,
|
||||
usage.num_cached_tokens ??
|
||||
usage.prompt_token_details?.cached_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps finish reasons and does not finalize truncated tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const [raw, normalized] of [
|
||||
["stop", "stop"],
|
||||
["model_length", "length"],
|
||||
["tool_calls", "tool-calls"],
|
||||
["error", "error"],
|
||||
["future_reason", "unknown"],
|
||||
] as const) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({}, raw)))),
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
const truncated = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({
|
||||
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
|
||||
}),
|
||||
chunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(truncated.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(truncated.events.some(LLMEvent.is.toolCall)).toBe(false)
|
||||
expect(truncated.events.some(LLMEvent.is.toolInputEnd)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-text output parts and rejects invalid stream endings", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: null }),
|
||||
chunk({
|
||||
content: [
|
||||
{ type: "reference", reference_ids: [1] },
|
||||
{ type: "image_url", image_url: "https://example.test/image.png" },
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
}),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.text).toBe("Answer")
|
||||
|
||||
const missingFinish = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(chunk({ content: "partial" })))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(missingFinish.message).toContain("without finish_reason")
|
||||
|
||||
const lateContent = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(chunk({}, "stop"), chunk({ content: [{ type: "text", text: "late" }] }))),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(lateContent.message).toContain("content after the finish reason")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses environment bearer auth and custom package settings", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Mistral.model("fixture-model", {
|
||||
baseURL: "https://mistral.test/v1",
|
||||
headers: { "x-app": "test" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { safePrompt: true },
|
||||
}),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://mistral.test/v1/chat/completions")
|
||||
expect(web.headers.get("authorization")).toBe("Bearer secret")
|
||||
expect(web.headers.get("x-app")).toBe("test")
|
||||
expect(input.text).toContain('"service_tier":"priority"')
|
||||
return input.respond(sseEvents(chunk({}, "stop")), { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MISTRAL_API_KEY: "secret" } }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { configure } from "@opencode-ai/ai/providers/mistral"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.MISTRAL_API_KEY ?? "fixture"
|
||||
const recorded = recordedTests({
|
||||
prefix: "mistral-chat",
|
||||
provider: "mistral",
|
||||
protocol: "mistral-chat",
|
||||
requires: ["MISTRAL_API_KEY"],
|
||||
})
|
||||
const glmRecorded = recordedTests({
|
||||
prefix: "mistral-chat-glm",
|
||||
provider: "mistral",
|
||||
protocol: "mistral-chat",
|
||||
requires: ["MISTRAL_API_KEY"],
|
||||
})
|
||||
|
||||
const weather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe("Mistral recorded", () => {
|
||||
recorded.effect.with(
|
||||
"streams text with usage",
|
||||
{ tags: ["text", "usage"], metadata: { model: "mistral-small-latest" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest"),
|
||||
prompt: "Reply with exactly one word: hello",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toMatch(/^(?:hello|hi)[!.]?$/i)
|
||||
expect(response.finishReason.normalized).toBe("stop")
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"replays native reasoning",
|
||||
{ tags: ["reasoning", "replay", "usage"], metadata: { model: "mistral-small-latest" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({ apiKey, providerOptions: { reasoningEffort: "high" } }).model("mistral-small-latest")
|
||||
const firstRequest = LLM.request({
|
||||
model,
|
||||
prompt: "Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.",
|
||||
generation: { maxTokens: 512, temperature: 0 },
|
||||
})
|
||||
const first = yield* LLMClient.generate(firstRequest)
|
||||
|
||||
expect(first.text.trim()).toBe("391")
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
|
||||
const followUp = LLMRequest.update(firstRequest, {
|
||||
messages: [...firstRequest.messages, first.message, Message.user("Reply with exactly: Done.")],
|
||||
generation: { maxTokens: 256, temperature: 0 },
|
||||
})
|
||||
const replay = yield* compileRequest(followUp)
|
||||
expect(replay.body.messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: expect.arrayContaining([expect.objectContaining({ type: "thinking" })]),
|
||||
}),
|
||||
)
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.text.trim()).toMatch(/Done\.?$/)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"drives a tool loop",
|
||||
{ tags: ["tool", "tool-loop", "usage"], metadata: { model: "mistral-small-latest" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
|
||||
const firstRequest = LLM.request({
|
||||
model,
|
||||
system: "Call lookup_weather exactly once with Paris.",
|
||||
prompt: "What is the weather?",
|
||||
tools: [weather],
|
||||
toolChoice: weather,
|
||||
generation: { maxTokens: 160, temperature: 0 },
|
||||
})
|
||||
const first = yield* LLMClient.generate(firstRequest)
|
||||
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
|
||||
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
|
||||
const call = first.toolCalls[0]
|
||||
if (!call) throw new Error("Mistral did not return a tool call")
|
||||
const followUp = LLMRequest.update(firstRequest, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...firstRequest.messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
|
||||
],
|
||||
generation: { maxTokens: 160, temperature: 0 },
|
||||
})
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
describe("Mistral hosted GLM recorded", () => {
|
||||
glmRecorded.effect.with(
|
||||
"streams an indexed tool call",
|
||||
{ tags: ["hosted-model", "tool", "tool-call"], metadata: { model: "zai-glm-5-2" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: configure({ apiKey }).model("zai-glm-5-2"),
|
||||
system: "Call lookup_weather exactly once with Paris.",
|
||||
prompt: "What is the weather?",
|
||||
tools: [weather],
|
||||
toolChoice: weather,
|
||||
generation: { maxTokens: 256, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
|
||||
expect(response.events.filter(LLMEvent.is.toolInputStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolInputDelta).length).toBeGreaterThan(0)
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles a done-only message once across replayed item events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Recovered" }],
|
||||
}
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Recovered")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Recovered",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
|
||||
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
|
||||
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const text = {
|
||||
type: "message",
|
||||
id: "msg_text",
|
||||
content: [{ type: "output_text", text: "Done-only text." }],
|
||||
}
|
||||
const refusal = {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
content: [{ type: "refusal", refusal: "Done-only refusal." }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_text",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_text",
|
||||
text: "Done-only text.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_refusal",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_refusal",
|
||||
text: "Done-only refusal.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a repeated message lifecycle as replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores a stale done-only message while another message is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Draft" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text: "Final",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
|
||||
@@ -96,6 +96,27 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits user messages with no content", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.user([]), Message.user("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -4032,7 +4032,6 @@ describe("OpenAI Responses route", () => {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
RouteID,
|
||||
ToolResultValue,
|
||||
TransportError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
@@ -83,6 +84,37 @@ describe("llm schema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToolResultValue", () => {
|
||||
test("uses the canonical schema guard", () => {
|
||||
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
|
||||
{ value: { type: "json", value: { ok: true } }, expected: true },
|
||||
{ value: { type: "text", value: "done" }, expected: true },
|
||||
{ value: { type: "error", value: "failed" }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
|
||||
{ value: { type: "content", value: "done" }, expected: false },
|
||||
{ value: { type: "json" }, expected: false },
|
||||
{ value: { type: "unknown", value: "done" }, expected: false },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
|
||||
expect(ToolResultValue.is(item.value)).toBe(item.expected)
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts canonical results with extra fields", () => {
|
||||
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
|
||||
expect(
|
||||
ToolResultValue.is({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
|
||||
metadata: { source: "tool" },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI.Usage", () => {
|
||||
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
||||
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
RateLimitError,
|
||||
} from "../src/index.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
@@ -66,6 +79,54 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
yield* client.push(TestLLM.stop(), new CompactionResponse({ replacement: [] }))
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
const compacted = new CompactionResponse({
|
||||
replacement: [
|
||||
Message.user("retained input"),
|
||||
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
|
||||
Message.user("retained tail"),
|
||||
],
|
||||
})
|
||||
yield* client.push(compacted, TestLLM.text("continued", "answer"))
|
||||
const operation = LLMClient.compact(request)
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
const gate = yield* client.gate()
|
||||
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* client.wait(1)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect(yield* Fiber.join(fiber)).toBe(compacted)
|
||||
const next = LLMRequest.update(request, { messages: compacted.replacement })
|
||||
expect((yield* LLMClient.generate(next)).text).toBe("continued")
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(next)
|
||||
return compacted
|
||||
})
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
yield* client.always(compacted)
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
expect(yield* client.requests()).toEqual([request, next, next, next])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js"
|
||||
import { normalizeToolHistory } from "../src/tool-history.js"
|
||||
|
||||
const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} })
|
||||
const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") =>
|
||||
Message.tool(ToolResultPart.make({ id, name, result: value, resultType }))
|
||||
|
||||
describe("tool history normalization", () => {
|
||||
test("fills missing local results before the next step", () => {
|
||||
const normalized = normalizeToolHistory([
|
||||
Message.assistant([toolCall("first"), toolCall("second")]),
|
||||
toolResult("first", "done", "wrong", "text"),
|
||||
Message.user("Continue."),
|
||||
Message.assistant(toolCall("trailing")),
|
||||
])
|
||||
|
||||
expect(normalized.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
|
||||
expect(normalized[2]?.content).toEqual([
|
||||
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
|
||||
])
|
||||
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
|
||||
})
|
||||
|
||||
test("normalizes empty results without changing whitespace or media", () => {
|
||||
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
|
||||
const normalized = normalizeToolHistory([
|
||||
Message.assistant([
|
||||
toolCall("text"),
|
||||
toolCall("content"),
|
||||
toolCall("error"),
|
||||
toolCall("mixed"),
|
||||
toolCall("whitespace"),
|
||||
]),
|
||||
toolResult("text", "", "text", "text"),
|
||||
toolResult("content", [], "content", "content"),
|
||||
toolResult("error", "", "error", "error"),
|
||||
toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"),
|
||||
toolResult("whitespace", " ", "whitespace", "text"),
|
||||
])
|
||||
|
||||
expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
|
||||
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
|
||||
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
|
||||
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
|
||||
])
|
||||
})
|
||||
|
||||
test("leaves unmatched and provider-executed history unchanged", () => {
|
||||
const hostedCall = ToolCallPart.make({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: {},
|
||||
providerExecuted: true,
|
||||
})
|
||||
const hostedResult = ToolResultPart.make({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
result: "",
|
||||
resultType: "text",
|
||||
providerExecuted: true,
|
||||
})
|
||||
const hosted = Message.assistant([hostedCall, hostedResult])
|
||||
const orphan = toolResult("orphan", "ignored", "orphan", "text")
|
||||
|
||||
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
|
||||
})
|
||||
})
|
||||
@@ -442,6 +442,33 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const malformed = { type: "content", value: [{ type: "text" }] }
|
||||
const dynamic = Tool.make({
|
||||
description: "Return caller-defined JSON.",
|
||||
jsonSchema: { type: "object", properties: {} },
|
||||
execute: () => Effect.succeed(malformed),
|
||||
})
|
||||
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ dynamic },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
|
||||
)
|
||||
|
||||
expect(dispatched.result).toEqual({ type: "json", value: malformed })
|
||||
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "dynamic",
|
||||
result: { type: "json", value: malformed },
|
||||
output: { structured: malformed, content: [] },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes tool calls for one step without looping by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = scriptedResponses([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/performance/results/
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
+22
-1
@@ -71,4 +71,25 @@ Environment options:
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
|
||||
|
||||
| Branch | Site |
|
||||
| ------------ | --------------------- |
|
||||
| `dev` | `app.dev.opencode.ai` |
|
||||
| `production` | `app.opencode.ai` |
|
||||
| `beta` | `beta.opencode.ai` |
|
||||
|
||||
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
|
||||
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
|
||||
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
|
||||
|
||||
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
|
||||
override this default. Connecting still requires the service's credentials.
|
||||
|
||||
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
|
||||
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
|
||||
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
|
||||
need AWS credentials.
|
||||
|
||||
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
|
||||
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
})
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
|
||||
await expect(shortcuts).toHaveCount(4)
|
||||
const boxes = await shortcuts.evaluateAll((items) =>
|
||||
items.map((item) => {
|
||||
const box = item.getBoundingClientRect()
|
||||
return { width: box.width, center: box.left + box.width / 2 }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
|
||||
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
|
||||
})
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = await input.textContent()
|
||||
|
||||
for (let count = 0; count < 2; count++) {
|
||||
await input.press("ControlOrMeta+a")
|
||||
expect(
|
||||
await input.evaluate((editor) => {
|
||||
const selection = window.getSelection()
|
||||
return {
|
||||
text: selection?.toString(),
|
||||
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
|
||||
}
|
||||
}),
|
||||
).toEqual({ text, inside: true })
|
||||
}
|
||||
|
||||
await page.keyboard.type("Replacement draft")
|
||||
await expect(input).toHaveText("Replacement draft")
|
||||
await expect(component.getByRole("status")).toHaveText("Ready")
|
||||
if (draft === "mixed-attachments") {
|
||||
await expect(component.getByAltText("layout.png")).toBeVisible()
|
||||
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
|
||||
// The ref can run before data-component is assigned, so count on every target.
|
||||
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
|
||||
return replace.apply(this, nodes)
|
||||
}
|
||||
})
|
||||
const component = await mount("opencode-composer-flow--failed-submission-restoration")
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
await input.press("Home")
|
||||
await input.press("Shift+ArrowRight")
|
||||
await input.pressSequentially("XY")
|
||||
await expect(input).toHaveText("XYreserve this draft on failure")
|
||||
await expect(input).toHaveAttribute("data-test-replacements", "1")
|
||||
|
||||
// Closing the model picker restores the controller's saved caret through its editor ref.
|
||||
await component.locator('[data-action="composer-model"]').click()
|
||||
await page.getByRole("menu").getByRole("textbox").press("Escape")
|
||||
await expect(input).toBeFocused()
|
||||
await input.pressSequentially("!")
|
||||
await expect(input).toHaveText("XY!reserve this draft on failure")
|
||||
await component.getByRole("button", { name: "Send", exact: true }).click()
|
||||
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
|
||||
await expect(input).toHaveText("Preserve this draft on failure")
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("keeps the comment options button pressed while its menu is open", async ({ mount, page }) => {
|
||||
const component = await mount("ui-line-comment--display")
|
||||
const trigger = component.locator('[data-slot="line-comment-v2-overflow"]')
|
||||
const rest = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
|
||||
await trigger.click()
|
||||
|
||||
await expect(page.getByRole("menu")).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("data-expanded", "")
|
||||
await expect(trigger).not.toHaveCSS("background-color", rest)
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
|
||||
|
||||
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "timeline-virtualizer-fixture"
|
||||
host.dataset.scrolls = "0"
|
||||
host.dataset.viewportResizes = "0"
|
||||
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ pinned: true, ready: false })
|
||||
const rows = Array.from(
|
||||
{ length: input.count },
|
||||
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
|
||||
)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
|
||||
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
|
||||
let viewport!: HTMLDivElement
|
||||
let content!: HTMLDivElement
|
||||
let container!: HTMLDivElement
|
||||
const timeline = createTimelineVirtualizer({
|
||||
sessionKey: () => "cold-reveal-fixture",
|
||||
projection: {
|
||||
rows: () => rows,
|
||||
rowByKey: () => rowByKey,
|
||||
activeMessageID: () => undefined,
|
||||
messageRowIndex: () => indexes,
|
||||
messageLastRowIndex: () => indexes,
|
||||
},
|
||||
showHeader: () => false,
|
||||
pinned: () => state.pinned,
|
||||
scroll: () => ({ overflow: false, jump: false }),
|
||||
setScrollRef: (element) => {
|
||||
if (!element) return
|
||||
viewport = element
|
||||
resize.observe(element, { box: "border-box" })
|
||||
},
|
||||
setContentRef: (element) => {
|
||||
content = element
|
||||
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
|
||||
},
|
||||
onPin: () => setState("pinned", true),
|
||||
onUnpin: () => setState("pinned", false),
|
||||
onScheduleScrollState: (element) => {
|
||||
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
|
||||
host.dataset.lastScrollTop = String(element.scrollTop)
|
||||
},
|
||||
onResumeScroll: () => {},
|
||||
onSelectionInteraction: () => {},
|
||||
onUserScroll: () => {},
|
||||
onHistoryScroll: () => {},
|
||||
canRenderImmediately: () => input.immediate ?? false,
|
||||
})
|
||||
|
||||
const resize = new ResizeObserver((entries) => {
|
||||
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
|
||||
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
|
||||
})
|
||||
const reveal = new MutationObserver(() => {
|
||||
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
|
||||
// Capture the first reveal, not a later frame after geometry has recovered.
|
||||
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||
host.dataset.firstReveal = JSON.stringify({
|
||||
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
|
||||
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
scrollTop: viewport.scrollTop,
|
||||
clipped: mounted
|
||||
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
|
||||
.map((element) => element.dataset.timelineKey),
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
reveal.disconnect()
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-testid="timeline-controls" data-pinned={state.pinned}>
|
||||
<button type="button" onClick={() => setState("ready", true)}>
|
||||
Complete Markdown
|
||||
</button>
|
||||
<button type="button" onClick={() => (container.style.display = "none")}>
|
||||
Hide viewport
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const parent = viewport.parentElement!
|
||||
host.dataset.scrolls = "0"
|
||||
// Keep the same scroller and complete Markdown while it has no layout box.
|
||||
viewport.remove()
|
||||
viewport.scrollTop = 0
|
||||
setState("ready", true)
|
||||
parent.prepend(viewport)
|
||||
container.style.removeProperty("display")
|
||||
}}
|
||||
>
|
||||
Reconnect ready rows
|
||||
</button>
|
||||
<div ref={container} style={{ height: "180px", width: "400px" }}>
|
||||
<timeline.View
|
||||
header={null}
|
||||
workspaceSession={() => false}
|
||||
deferred={() => false}
|
||||
renderRow={(row) => (
|
||||
<div
|
||||
data-component="markdown"
|
||||
data-markdown-ready={state.ready ? "" : undefined}
|
||||
style={{ height: `${input.rowHeight}px` }}
|
||||
>
|
||||
{row().userMessageID}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<Fixture />
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
const gap = () =>
|
||||
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
|
||||
const viewport = element.closest("[data-scrollable]")!
|
||||
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(gap).toBe(16)
|
||||
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect.poll(gap).toBe(16)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect.poll(gap).toBe(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect.poll(gap).toBe(16)
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: [96, 97, 98, 99],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
})
|
||||
})
|
||||
|
||||
for (const input of [
|
||||
{ name: "offset-only", count: 1, rowHeight: 600 },
|
||||
{ name: "zero-height", count: 4, rowHeight: 60 },
|
||||
]) {
|
||||
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
|
||||
await page.evaluate(
|
||||
async ({ fixture, input }) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer(input)
|
||||
},
|
||||
{ fixture, input },
|
||||
)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(root).toHaveAttribute("data-observed-height", "180")
|
||||
await expect(content).toHaveCSS("visibility", "hidden")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
|
||||
|
||||
if (input.name === "offset-only") {
|
||||
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
|
||||
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
|
||||
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
|
||||
}
|
||||
if (input.name === "zero-height") {
|
||||
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
|
||||
// Wait for ResizeObserver to clear the actual range, not just for display:none.
|
||||
await expect(root).toHaveAttribute("data-observed-height", "0")
|
||||
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
|
||||
}
|
||||
await expect(root).not.toHaveAttribute("data-first-reveal")
|
||||
const resizes = await root.getAttribute("data-viewport-resizes")
|
||||
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
|
||||
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
|
||||
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
|
||||
pendingMarkdown: 0,
|
||||
clipped: [],
|
||||
viewportHeight: 180,
|
||||
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
|
||||
})
|
||||
if (input.name === "offset-only") {
|
||||
// This repair must not depend on another native scroll or resize delivery.
|
||||
await expect(root).toHaveAttribute("data-scrolls", "0")
|
||||
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
|
||||
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
||||
```
|
||||
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
||||
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
|
||||
|
||||
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
||||
|
||||
@@ -81,13 +81,62 @@ Committed smoke and regression tests continue to own correctness coverage for pa
|
||||
|
||||
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
|
||||
|
||||
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
|
||||
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
|
||||
|
||||
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
|
||||
|
||||
Cold means the destination transcript has never loaded or rendered in that fresh browser context. Its measured switch includes one fixture message fetch. Warm means its complex answer was rendered and ready before switching away and back, and asserts no message fetch during the measured switch. Neither includes app startup or the source session's Markdown engine initialization. These cold results are not comparable to older prefetched cold-render results. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
|
||||
```
|
||||
|
||||
**The tab-switch fixture returns full history, not normal pagination.** Measure cold API navigation, Home-row opening, and restored-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
|
||||
`inactive-tab-prefetch-benchmark.spec.ts` restores eight tabs with normal 20-message pages (44 parts and 139,257 response bytes per page). It gates heavy responses independently until every tab's attention callback has run, then measures selection with ready answer Markdown and bottom anchoring. A separate case closes an inactive tab before releasing the responses. The fixture reports speculative transcript/inbox reads, request concurrency, response bytes, and activation latency. Set `OPENCODE_PERFORMANCE_MEMORY=1` only in separate retention runs; those force GC before selection and must not be mixed into clean timing results. The scope is the production browser renderer, not total desktop memory. Live background events and eviction of previously visited transcripts are separate workloads.
|
||||
|
||||
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
|
||||
|
||||
For a repeatable tab-switch summary, run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
|
||||
|
||||
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from cached tab remounts.
|
||||
|
||||
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
|
||||
|
||||
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
|
||||
|
||||
```sh
|
||||
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
|
||||
```
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
|
||||
|
||||
### Cache-Enabled HTTP Fixture
|
||||
|
||||
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
|
||||
```
|
||||
|
||||
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
|
||||
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
|
||||
bun run bench:tabs
|
||||
```
|
||||
|
||||
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
|
||||
|
||||
## Retained renderer memory
|
||||
|
||||
Run the catalog workload against the production app bundle:
|
||||
@@ -111,7 +160,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
|
||||
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
||||
|
||||
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
||||
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
|
||||
|
||||
```sh
|
||||
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||
|
||||
@@ -5,16 +5,20 @@ type BenchmarkFixtures = {
|
||||
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
||||
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
||||
benchmarkResult: void
|
||||
traceScope: "page" | "interaction"
|
||||
}
|
||||
|
||||
export type PerformancePageDiagnostics = {
|
||||
navigations: string[]
|
||||
traceScope: "page" | "interaction"
|
||||
startTrace: () => Promise<void>
|
||||
stop: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
||||
|
||||
export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
traceScope: ["page", { option: true }],
|
||||
reportState: async ({}, use) => use({}),
|
||||
report: async ({ reportState }, use) => {
|
||||
await use((metrics, context = {}) => {
|
||||
@@ -49,9 +53,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
page: async ({ page }, use, testInfo) => {
|
||||
page: async ({ page, traceScope }, use, testInfo) => {
|
||||
const name = benchmarkName(testInfo)
|
||||
const diagnostics = await observePerformancePage(page, name)
|
||||
const diagnostics = await observePerformancePage(page, name, traceScope)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
@@ -75,25 +79,30 @@ function benchmarkName(testInfo: TestInfo) {
|
||||
|
||||
export { expect }
|
||||
|
||||
async function observePerformancePage(page: Page, name: string) {
|
||||
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
|
||||
const navigations: string[] = []
|
||||
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||
}
|
||||
page.on("framenavigated", onNavigation)
|
||||
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
|
||||
let stopping: Promise<string | undefined> | undefined
|
||||
const diagnostics: PerformancePageDiagnostics = {
|
||||
navigations,
|
||||
traceScope,
|
||||
async startTrace() {
|
||||
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
|
||||
page.off("framenavigated", onNavigation)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
stop() {
|
||||
page.off("framenavigated", onNavigation)
|
||||
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
||||
},
|
||||
}
|
||||
pages.set(page, diagnostics)
|
||||
if (traceScope === "page") await diagnostics.startTrace()
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
@@ -130,6 +139,7 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
|
||||
context: {
|
||||
platform: process.platform,
|
||||
trace,
|
||||
traceScope: diagnostics.traceScope,
|
||||
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
||||
},
|
||||
navigations: diagnostics.navigations,
|
||||
|
||||
@@ -14,7 +14,6 @@ const categories = [
|
||||
"blink.console",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
@@ -34,6 +33,9 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
|
||||
.map((category) => category.slice(1)),
|
||||
includedCategories: [
|
||||
...categories.filter((category) => !category.startsWith("-")),
|
||||
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
|
||||
? ["disabled-by-default-devtools.timeline.stack"]
|
||||
: []),
|
||||
...(selectors
|
||||
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Composer History Hydration
|
||||
|
||||
Manual benchmark for an empty destination composer. Runs the production
|
||||
`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence
|
||||
codec, and browser IndexedDB draft store. It does not run the surrounding app
|
||||
shell or native desktop IPC.
|
||||
|
||||
Workload: 100 normal prompts with realistic review instructions/code and 100
|
||||
shell commands. Separate cases have no images, 50 unique screenshots, or 50
|
||||
references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code
|
||||
screenshots before timing and reports their exact byte sizes. Each isolated
|
||||
browser context measures a cold URL-cache mount followed by a warm remount.
|
||||
The database was just seeded; this does not simulate a cold disk cache.
|
||||
|
||||
`historyReadyMs` measures the mount action until both production history stores
|
||||
are populated. This is history availability, not time to first editable input
|
||||
(input can be usable before history finishes). The benchmark then verifies
|
||||
ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs`
|
||||
includes Playwright action/assertion overhead and is reported separately.
|
||||
`mountRecallObservedMs` includes the mount, readiness checks, keyboard action,
|
||||
and correct text/image completion; it also includes Playwright overhead.
|
||||
IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or
|
||||
process memory. No timing threshold is enforced.
|
||||
|
||||
From `packages/app`, set `OPENCODE_HISTORY_BUILD` and
|
||||
`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run:
|
||||
|
||||
```sh
|
||||
bun x vite build --config e2e/performance/composer-history/vite.config.ts
|
||||
bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
The preview server owns port 4783 and is stopped by Playwright. Preserve each
|
||||
build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all
|
||||
raw samples. Optional Chrome traces use the existing
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean
|
||||
timing. Screenshots are captured after timing on the first repeat only.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
benchmark.use({ traceScope: "page" })
|
||||
for (const shape of ["text", "unique", "repeated"]) {
|
||||
benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
await page.goto(`/?shape=${shape}`)
|
||||
const button = page.getByRole("button", { name: "Mount empty composer", exact: true })
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const samples = []
|
||||
for (const cache of ["cold", "warm"]) {
|
||||
await expect(button).toBeEnabled()
|
||||
const mountStarted = performance.now()
|
||||
await button.click()
|
||||
await expect(page.getByTestId("history-ready")).toHaveText("ready")
|
||||
await expect(input).toBeEditable()
|
||||
await expect(input).toBeEmpty()
|
||||
const result = JSON.parse((await page.getByTestId("history-result").textContent())!)
|
||||
expect(result.documents).toBe(2)
|
||||
expect(result.historyReadyMs).toBeGreaterThan(0)
|
||||
const start = performance.now()
|
||||
await input.press("ArrowUp")
|
||||
await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.")
|
||||
const images = page.getByRole("img", { name: "request-0.png", exact: true })
|
||||
await expect(images).toHaveCount(shape === "text" ? 0 : 1)
|
||||
if (shape !== "text")
|
||||
await expect
|
||||
.poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440))
|
||||
.toBe(true)
|
||||
samples.push({
|
||||
cache,
|
||||
...result,
|
||||
recallObservedMs: performance.now() - start,
|
||||
mountRecallObservedMs: performance.now() - mountStarted,
|
||||
})
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
report(
|
||||
{ samples },
|
||||
{
|
||||
browser: page.context().browser()!.version(),
|
||||
scope: "production composer editor/history, browser IndexedDB; no native IPC",
|
||||
},
|
||||
)
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
|
||||
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createComposerHistory } from "@/composer/history/store"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import type { ComposerPersistedState } from "@/composer/types"
|
||||
import "@/index.css"
|
||||
|
||||
const shape = new URLSearchParams(location.search).get("shape") ?? "text"
|
||||
const normal = Array.from({ length: 100 }, (_, index) => {
|
||||
const content =
|
||||
`Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` +
|
||||
`The request should stop after three attempts. Add coverage for a 429 response, a connection reset, and a successful retry. Verify that only idempotent requests are retried.\n\n` +
|
||||
`Report ${index}:\n\`\`\`ts\nexport async function request(input: Request) {\n const response = await fetch(input)\n if (!response.ok) throw new Error(response.statusText)\n return response.json()\n}\n\`\`\``
|
||||
return {
|
||||
prompt: [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...(shape !== "text" && index % 2 === 0
|
||||
? [
|
||||
{
|
||||
type: "image",
|
||||
id: `attachment-${index}`,
|
||||
filename: `request-${index}.png`,
|
||||
mime: "image/png",
|
||||
blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
comments: [],
|
||||
}
|
||||
})
|
||||
const shell = Array.from({ length: 100 }, (_, index) => {
|
||||
const content = `bun test src/network/request-${index}.test.ts --timeout 30000`
|
||||
return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] }
|
||||
})
|
||||
|
||||
// Seed only this Playwright context, before opening the production draft store.
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("documents")
|
||||
request.result.createObjectStore("blobs")
|
||||
}
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))]
|
||||
const screenshots: { id: string; blob: Blob }[] = []
|
||||
for (const id of ids) {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 1440
|
||||
canvas.height = 900
|
||||
const context = canvas.getContext("2d")!
|
||||
context.fillStyle = "#15191f"
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.font = "16px monospace"
|
||||
context.fillStyle = "#b8c8d8"
|
||||
context.fillText(`request.ts - ${id}`, 30, 35)
|
||||
for (let line = 0; line < 38; line++) {
|
||||
context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882"
|
||||
context.fillText(
|
||||
`${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`,
|
||||
30,
|
||||
70 + line * 20,
|
||||
)
|
||||
}
|
||||
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"))
|
||||
screenshots.push({ id, blob })
|
||||
}
|
||||
const transaction = db.transaction(["documents", "blobs"], "readwrite")
|
||||
transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history")
|
||||
transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell")
|
||||
screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
db.close()
|
||||
|
||||
const metrics = { reads: 0, blobBytes: 0, documents: 0 }
|
||||
const originalGet = IDBObjectStore.prototype.get
|
||||
IDBObjectStore.prototype.get = function (key) {
|
||||
const request = originalGet.call(this, key)
|
||||
if (this.name === "documents") metrics.documents++
|
||||
if (this.name === "blobs") {
|
||||
metrics.reads++
|
||||
request.addEventListener("success", () => {
|
||||
metrics.blobBytes += request.result?.size ?? 0
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
}
|
||||
const [state, setState] = createStore({ mount: 0, ready: false, result: "" })
|
||||
const workload = {
|
||||
shape,
|
||||
normalEntries: normal.length,
|
||||
shellEntries: shell.length,
|
||||
imageReferences: shape === "text" ? 0 : 50,
|
||||
uniqueImages: ids.length,
|
||||
storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0),
|
||||
documentBytes: [normal, shell].reduce(
|
||||
(sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length,
|
||||
0,
|
||||
),
|
||||
screenshotDimensions: [1440, 900],
|
||||
}
|
||||
let started = 0
|
||||
function mount() {
|
||||
metrics.reads = 0
|
||||
metrics.blobBytes = 0
|
||||
metrics.documents = 0
|
||||
setState({ ready: false, result: "" })
|
||||
started = performance.now()
|
||||
setState("mount", state.mount + 1)
|
||||
}
|
||||
function Destination() {
|
||||
// Same history creation and editor mapping as createComposerModel. Destination draft is empty.
|
||||
const history = createComposerHistory()
|
||||
const store = createStore<ComposerPersistedState>({
|
||||
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const controller = createComposerEditor({
|
||||
store,
|
||||
commands: () => [],
|
||||
context: () => [],
|
||||
searchContextFiles: () => [],
|
||||
history: {
|
||||
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
|
||||
add: (prompt, mode) => history.add(prompt, mode, []),
|
||||
},
|
||||
view: {
|
||||
placeholder: () => "Empty destination composer",
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
createEffect(() => {
|
||||
if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return
|
||||
setState({
|
||||
ready: true,
|
||||
result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }),
|
||||
})
|
||||
})
|
||||
return <ComposerEditor controller={controller} />
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<main style={{ padding: "40px", width: "900px" }}>
|
||||
<h1>Composer global history: {shape}</h1>
|
||||
<button onClick={mount}>Mount empty composer</button>
|
||||
<output data-testid="history-ready">{state.ready ? "ready" : "idle"}</output>
|
||||
<pre data-testid="history-result">{state.result}</pre>
|
||||
<Show when={state.mount} keyed>
|
||||
{(_mount) => <Destination />}
|
||||
</Show>
|
||||
</main>
|
||||
</PlatformProvider>
|
||||
),
|
||||
document.getElementById("root")!,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Composer history benchmark</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "composer-history.bench.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
reporter: "line",
|
||||
outputDir: process.env.OPENCODE_HISTORY_OUTPUT,
|
||||
use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
command:
|
||||
"bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort",
|
||||
url: "http://127.0.0.1:4783",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import app from "../../../vite"
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
|
||||
plugins: [app],
|
||||
build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true },
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
# Timeline Preload Lifetime
|
||||
|
||||
This manual benchmark uses the production app, restored session tabs, the real
|
||||
`MessageTimeline` preload, and the real Markdown worker. Only API data and result
|
||||
delivery timing are fixture-owned. It does not connect to a running OpenCode
|
||||
service or send prompts.
|
||||
|
||||
From `packages/app`, set absolute `MARKDOWN_APP_BUILD_DIR` and
|
||||
`MARKDOWN_RESULTS_DIR` artifact paths, then run:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
# Copy dist into MARKDOWN_APP_BUILD_DIR before editing production source.
|
||||
bun --bun x playwright test --config e2e/performance/markdown/playwright.config.ts --repeat-each 20
|
||||
```
|
||||
|
||||
Each isolated sample restores two sessions with one user message and one completed
|
||||
assistant text part each. The cold target has a realistic recovery review with
|
||||
either two TypeScript fences (typical) or 36 fences (large). The source has a short
|
||||
completed answer. Target data is prefetched before selection, but its Markdown is
|
||||
not parsed until the target is selected.
|
||||
|
||||
The app's service-worker generator reads `dist`, so use the normal build output
|
||||
and freeze a copy, rather than overriding Vite's build output directory.
|
||||
|
||||
The real worker result is held after admission. The test selects the original
|
||||
session again and releases the held result only after the abandoned timeline row
|
||||
detaches and the selected answer reports production Markdown readiness. This
|
||||
exercises both the timeline preload and the nested Markdown consumer, including
|
||||
the case where either one would otherwise keep a shared parse alive.
|
||||
|
||||
Destination readiness and post-disposal result settlement are separate metrics.
|
||||
The latter is a MessageChannel task after the result's promise microtasks drain.
|
||||
The DOMParser probe counts actual DOMPurify input containing the abandoned answer
|
||||
after disposal, in characters. CDP reports renderer task/script time and JS heap.
|
||||
These are not worker CPU, Electron process RAM, or ungated tab-switch measurements.
|
||||
In particular, this gate releases the result after destination readiness and must
|
||||
not be used to claim a destination-readiness gain from skipping sanitization.
|
||||
|
||||
`MARKDOWN_ASSERT_DISPOSAL=1` enables the no-obsolete-sanitization assertion. Use
|
||||
`MARKDOWN_RETAINED=1` only in separate post-GC runs. The repository trace collector
|
||||
is available through `OPENCODE_PERFORMANCE_TRACE_DIR`, and `MARKDOWN_SCREENSHOT`
|
||||
captures final output after timing. Run serially, preserve frozen builds, and keep
|
||||
all results outside Git.
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer"
|
||||
import { installMarkdownGate } from "./probe"
|
||||
|
||||
for (const size of ["typical", "large"]) {
|
||||
benchmark(`timeline preload disposal: ${size}`, async ({ page, report }) => {
|
||||
const answer = completedAnswer(size === "typical" ? 2 : 36)
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{
|
||||
id: `msg_1_${id}_user`,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review the recovery boundary.",
|
||||
},
|
||||
{
|
||||
id: `msg_2_${id}_assistant`,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000008000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
id === fixture.targetID
|
||||
? answer
|
||||
: "## Current destination\n\nThe selected session is ready.\n\n```typescript\nconst current = { ready: true }\n```",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions.filter((session) => session.id !== fixture.childID),
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
const targetPart = `msg_2_${fixture.targetID}_assistant:text:0`
|
||||
const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0`
|
||||
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) })
|
||||
const prefetched = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await prefetched
|
||||
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const stats = await page.evaluate(() => {
|
||||
const value = Reflect.get(window, "markdownGate")
|
||||
return {
|
||||
admitted: value.admitted,
|
||||
responses: value.responses,
|
||||
started: value.started,
|
||||
ready: value.ready,
|
||||
released: value.released,
|
||||
settled: value.settled,
|
||||
sanitizeCalls: value.sanitizeCalls,
|
||||
sanitizeChars: value.sanitizeChars,
|
||||
}
|
||||
})
|
||||
expect(stats.admitted).toBe(1)
|
||||
expect(stats.responses).toBe(1)
|
||||
expect(stats.ready).toBeGreaterThan(stats.started)
|
||||
expect(stats.settled).toBeGreaterThan(stats.released)
|
||||
expect(errors).toEqual([])
|
||||
if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.sanitizeCalls).toBe(0)
|
||||
const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value
|
||||
const retained = process.env.MARKDOWN_RETAINED === "1"
|
||||
if (retained) await cdp.send("HeapProfiler.collectGarbage")
|
||||
report(
|
||||
{
|
||||
...stats,
|
||||
destinationReadyMs: stats.ready - stats.started,
|
||||
releasedSettledMs: stats.settled - stats.released,
|
||||
taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000,
|
||||
scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000,
|
||||
usedHeapBytes: (await cdp.send("Runtime.getHeapUsage")).usedSize,
|
||||
},
|
||||
{
|
||||
size,
|
||||
retained,
|
||||
answerBytes: Buffer.byteLength(answer),
|
||||
messagesPerSession: 2,
|
||||
partsPerAnswer: 1,
|
||||
fences: size === "typical" ? 2 : 36,
|
||||
browser: page.context().browser()!.version(),
|
||||
transport: "playwright-route",
|
||||
build: process.env.MARKDOWN_APP_BUILD_DIR,
|
||||
},
|
||||
)
|
||||
if (process.env.MARKDOWN_SCREENSHOT)
|
||||
await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/timeline-${size}.png` })
|
||||
await cdp.detach()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.env.PLAYWRIGHT_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = "127.0.0.1"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
outputDir: process.env.MARKDOWN_RESULTS_DIR,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [["line"]],
|
||||
use: { baseURL: "http://127.0.0.1:6199", viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
|
||||
command: `bun run serve -- --host 127.0.0.1 --port 6199 --strictPort --outDir "${process.env.MARKDOWN_APP_BUILD_DIR}"`,
|
||||
url: "http://127.0.0.1:6199",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type {
|
||||
MarkdownWorkerRequest,
|
||||
MarkdownWorkerResponse,
|
||||
} from "../../../../session-ui/src/components/markdown-worker-protocol"
|
||||
|
||||
export async function installMarkdownGate(
|
||||
page: Page,
|
||||
input: { answer: string; sourcePart: string; targetPart: string; href: string },
|
||||
) {
|
||||
await page.addInitScript(({ answer, sourcePart, targetPart, href }) => {
|
||||
const stats = {
|
||||
admitted: 0,
|
||||
responses: 0,
|
||||
held: false,
|
||||
started: 0,
|
||||
ready: 0,
|
||||
released: 0,
|
||||
settled: 0,
|
||||
sanitizeCalls: 0,
|
||||
sanitizeChars: 0,
|
||||
arm: () => {
|
||||
armed = true
|
||||
},
|
||||
}
|
||||
let armed = false
|
||||
let id: number | undefined
|
||||
let release: (() => void) | undefined
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")!
|
||||
const post = Worker.prototype.postMessage
|
||||
Object.defineProperty(Worker.prototype, "onmessage", {
|
||||
configurable: true,
|
||||
get: descriptor.get,
|
||||
set(callback: (event: MessageEvent<MarkdownWorkerResponse>) => void) {
|
||||
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
if (event.data.type === "parse" && event.data.id === id) {
|
||||
stats.responses++
|
||||
stats.held = true
|
||||
release = () => callback.call(this, event)
|
||||
return
|
||||
}
|
||||
callback.call(this, event)
|
||||
})
|
||||
},
|
||||
})
|
||||
Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) {
|
||||
if (request.type === "parse" && request.text === answer) {
|
||||
id = request.id
|
||||
stats.admitted++
|
||||
}
|
||||
post.call(this, request)
|
||||
}
|
||||
const parse = DOMParser.prototype.parseFromString
|
||||
DOMParser.prototype.parseFromString = function (text, type) {
|
||||
if (stats.released && String(text).includes("Recovery implementation review")) {
|
||||
stats.sanitizeCalls++
|
||||
stats.sanitizeChars += String(text).length
|
||||
}
|
||||
return parse.call(this, text, type)
|
||||
}
|
||||
document.addEventListener(
|
||||
"mousedown",
|
||||
(event) => {
|
||||
if (!armed || stats.started) return
|
||||
const target = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (target?.getAttribute("href") !== href) return
|
||||
stats.started = performance.now()
|
||||
},
|
||||
true,
|
||||
)
|
||||
// The app can retain the outgoing view until the destination is ready. Release
|
||||
// only after its actual row detaches, rather than assuming click means dispose.
|
||||
new MutationObserver(() => {
|
||||
if (!stats.started || stats.released) return
|
||||
const current = document.querySelector(`[data-timeline-part-id="${sourcePart}"] [data-markdown-ready]`)
|
||||
if (!current) return
|
||||
stats.ready ||= performance.now()
|
||||
if (document.querySelector(`[data-timeline-part-id="${targetPart}"]`)) return
|
||||
stats.released = performance.now()
|
||||
performance.mark("markdown-timeline-disposed")
|
||||
release!()
|
||||
release = undefined
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = () => {
|
||||
stats.settled = performance.now()
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
channel.port2.postMessage(null)
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true })
|
||||
Object.defineProperty(window, "markdownGate", { value: stats })
|
||||
}, input)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Patch Group Benchmark
|
||||
|
||||
This manual benchmark mounts the production `CurrentFileToolGroup` and `File`
|
||||
components with completed edit results. A separate case mounts `ToolDisplay`
|
||||
with a patch result. It uses four real Core tool source files, with deterministic
|
||||
identifier renames, rather than repeated filler. It does not connect to a server.
|
||||
|
||||
From `packages/app`, set `PATCH_BUILD_DIR` and `PATCH_RESULTS_DIR` to external
|
||||
artifact directories, then run:
|
||||
|
||||
```sh
|
||||
bun x vite build --config e2e/performance/patch-groups/vite.config.ts
|
||||
bun x playwright test --config e2e/performance/patch-groups/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
Run under the shared exclusive gate when collecting measurements on a shared
|
||||
machine. The Playwright-owned static server uses `PATCH_PORT` (default 4317),
|
||||
refuses to reuse an existing server, and shuts down after the run.
|
||||
|
||||
Each fresh browser context measures a cold collapsed mount, a warm remount,
|
||||
and opening `edit.ts` through its real accordion. Mount timing covers synchronous
|
||||
component construction through layout. Expansion timing starts at the click and
|
||||
ends at the production file renderer's `onRendered` callback. Assertions check
|
||||
the exact file count, collapsed state, and completed file rendering. Results
|
||||
include payload bytes, source bytes, file/tool counts, and supporting warm
|
||||
`patchFileGroups` timings with and without reading views. No timing thresholds
|
||||
are enforced. This is a browser component workload, not a full desktop memory test.
|
||||
|
||||
Freeze the build before changing production code. Use the same fixture, browser,
|
||||
viewport, sample count, and completion checks for both revisions.
|
||||
|
||||
`PATCH_REVISION=<git-sha>` loads the grouping module and tool renderer from that
|
||||
revision at build time without changing the worktree. This is useful when fixing
|
||||
the harness after freezing a baseline. All other production sources must match
|
||||
between revisions; this switch only covers those two measured modules.
|
||||
|
||||
For a separate diagnostic build, set `PATCH_COUNTERS=1`. Its build-only transform
|
||||
counts grouping, normalization, reconstruction, and line-diff calls with User
|
||||
Timing marks. Do not mix instrumented results with clean timings. Set
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR` for the existing Chrome trace collector, and
|
||||
`PATCH_SCREENSHOTS=1` for collapsed/expanded screenshots after measurement.
|
||||
@@ -0,0 +1,143 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { render } from "solid-js/web"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { CurrentSessionProviders } from "../../../../session-ui/src/storybook/current-session-story"
|
||||
import { emptySessionDocument } from "../../../../session-ui/src/storybook/current-session-fixtures"
|
||||
import { CurrentFileToolGroup, ToolDisplay } from "../../../../session-ui/src/tools/tool-renderer"
|
||||
import { patchFileGroups } from "../../../../session-ui/src/components/apply-patch-file"
|
||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import edit from "../../../../core/src/tool/plugin/edit.ts?raw"
|
||||
import patch from "../../../../core/src/tool/plugin/patch.ts?raw"
|
||||
import read from "../../../../core/src/tool/plugin/read.ts?raw"
|
||||
import shell from "../../../../core/src/tool/plugin/shell.ts?raw"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const scenario = new URLSearchParams(location.search).get("scenario") ?? "complete"
|
||||
const sources = [edit, patch, read, shell].map((text) => text.replaceAll("\r\n", "\n"))
|
||||
const names = ["edit", "patch", "read", "shell"]
|
||||
const changed = (text: string) => text.replaceAll(/\bcontext\b/g, "invocation")
|
||||
const entry = (index: number, before: string, after: string) => ({
|
||||
file: `src/tool/plugin/${names[index]}.ts`,
|
||||
patch: createTwoFilesPatch(names[index], names[index], before, after, "", "", {
|
||||
context: scenario === "partial" ? 3 : Infinity,
|
||||
}),
|
||||
...diffLines(before, after).reduce(
|
||||
(counts, item) => ({
|
||||
additions: counts.additions + (item.added ? item.count : 0),
|
||||
deletions: counts.deletions + (item.removed ? item.count : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
),
|
||||
status: "modified" as const,
|
||||
})
|
||||
const files =
|
||||
scenario === "multi"
|
||||
? sources.map((text, index) => entry(index, text, changed(text)))
|
||||
: [
|
||||
entry(0, sources[0], changed(sources[0])),
|
||||
...(scenario === "chained"
|
||||
? [entry(0, changed(sources[0]), changed(sources[0]).replaceAll(/\binput\b/g, "parameters"))]
|
||||
: []),
|
||||
]
|
||||
const tools: SessionMessageAssistantTool[] = files.map((file, index) => ({
|
||||
id: `fixture-edit-${index}`,
|
||||
type: "tool",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: file.file, oldString: "context", newString: "invocation", replaceAll: true },
|
||||
metadata: { files: [file] },
|
||||
content: [{ type: "text", text: `Edited ${file.file}` }],
|
||||
},
|
||||
time: { created: 1, ran: 2, completed: 3 },
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
patchBenchmark: {
|
||||
payloadBytes: number
|
||||
sourceBytes: number
|
||||
files: number
|
||||
tools: number
|
||||
grouping: (expanded: boolean) => { ms: number; groups: number; views: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
window.patchBenchmark = {
|
||||
payloadBytes: new TextEncoder().encode(JSON.stringify(tools)).length,
|
||||
sourceBytes: new TextEncoder().encode(sources.slice(0, scenario === "multi" ? 4 : 1).join("")).length,
|
||||
files: new Set(files.map((file) => file.file)).size,
|
||||
tools: tools.length,
|
||||
grouping(expanded) {
|
||||
const start = performance.now()
|
||||
const groups = patchFileGroups(files)
|
||||
const views = expanded ? groups.reduce((count, file) => count + file.views.length, 0) : 0
|
||||
return { ms: performance.now() - start, groups: groups.length, views }
|
||||
},
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ mounted: false, duration: 0, rendered: 0 })
|
||||
let start = 0
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<section style={{ margin: "24px auto", "max-width": "960px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
start = performance.now()
|
||||
setState("mounted", true)
|
||||
document.querySelector("[data-component=apply-patch-tool]")!.getBoundingClientRect()
|
||||
setState("duration", performance.now() - start)
|
||||
}}
|
||||
>
|
||||
Mount tools
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setState({ mounted: false, rendered: 0 })
|
||||
}}
|
||||
>
|
||||
Unmount tools
|
||||
</button>
|
||||
<output data-testid="mount-ms">{state.duration}</output>
|
||||
<output data-testid="rendered">{state.rendered}</output>
|
||||
<div
|
||||
on:click={{
|
||||
capture: true,
|
||||
handleEvent() {
|
||||
start = performance.now()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Show when={state.mounted}>
|
||||
<CurrentSessionProviders document={emptySessionDocument}>
|
||||
<Show
|
||||
when={scenario === "direct"}
|
||||
fallback={
|
||||
<CurrentFileToolGroup
|
||||
tools={tools}
|
||||
onSizeChange={() => setState("rendered", performance.now() - start)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ToolDisplay
|
||||
id="fixture-patch"
|
||||
tool="patch"
|
||||
input={{}}
|
||||
metadata={{ files }}
|
||||
status="completed"
|
||||
onContentRendered={() => setState("rendered", performance.now() - start)}
|
||||
/>
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
render(() => <Fixture />, document.getElementById("root")!)
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Patch groups benchmark</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="./fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
for (const scenario of ["complete", "partial", "chained", "multi", "direct"]) {
|
||||
benchmark(`patch groups ${scenario}`, async ({ page, report }, info) => {
|
||||
await page.goto(`/?scenario=${scenario}`)
|
||||
await expect(page.getByRole("button", { name: "Mount tools", exact: true })).toBeEnabled()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('13px "Inter"'))).toBe(true)
|
||||
const shape = await page.evaluate(() => {
|
||||
const { grouping, ...shape } = window.patchBenchmark
|
||||
performance.clearMarks()
|
||||
return shape
|
||||
})
|
||||
const mount = async () => {
|
||||
await page.getByRole("button", { name: "Mount tools", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="apply-patch-filename"]')).toHaveCount(shape.files)
|
||||
await expect(page.locator('[data-component="file"]')).toHaveCount(0)
|
||||
return Number(await page.getByTestId("mount-ms").textContent())
|
||||
}
|
||||
const cold = await mount()
|
||||
const counters = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
|
||||
name,
|
||||
performance.getEntriesByName(`patch-counter:${name}`).length,
|
||||
]),
|
||||
),
|
||||
)
|
||||
await page.getByRole("button", { name: "Unmount tools", exact: true }).click()
|
||||
await expect(page.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
|
||||
await page.evaluate(() => performance.clearMarks())
|
||||
const warm = await mount()
|
||||
const warmCounters = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
|
||||
name,
|
||||
performance.getEntriesByName(`patch-counter:${name}`).length,
|
||||
]),
|
||||
),
|
||||
)
|
||||
const file = page.locator('[data-scope="apply-patch"] button').filter({ hasText: "edit.ts" })
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByTestId("rendered")).not.toHaveText("0")
|
||||
await expect(page.locator('[data-component="file"]')).toBeVisible()
|
||||
const expansion = Number(await page.getByTestId("rendered").textContent())
|
||||
const grouping = await page.evaluate(() => ({
|
||||
collapsed: window.patchBenchmark.grouping(false),
|
||||
expanded: window.patchBenchmark.grouping(true),
|
||||
}))
|
||||
expect(grouping.collapsed.groups).toBe(shape.files)
|
||||
report(
|
||||
{ cold, warm, expansion, grouping, counters, warmCounters },
|
||||
{ scenario, ...shape, scope: "production tool components" },
|
||||
)
|
||||
if (process.env.PATCH_SCREENSHOTS === "1") {
|
||||
await page.screenshot({ path: info.outputPath(`${scenario}-expanded.png`) })
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await page
|
||||
.locator('[data-component="apply-patch-tool"]')
|
||||
.screenshot({ path: info.outputPath(`${scenario}-collapsed.png`) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const baseURL = `http://127.0.0.1:${process.env.PATCH_PORT ?? 4317}`
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
outputDir: process.env.PATCH_RESULTS_DIR,
|
||||
reporter: "line",
|
||||
use: { baseURL, viewport: { width: 1366, height: 768 }, colorScheme: "light" },
|
||||
webServer: {
|
||||
command: "bun serve.ts",
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "node:path"
|
||||
|
||||
const directory = process.env.PATCH_BUILD_DIR
|
||||
if (!directory) throw new Error("PATCH_BUILD_DIR is required")
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.PATCH_PORT ?? 4317),
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const file = Bun.file(path.join(directory, pathname === "/" ? "index.html" : pathname))
|
||||
return (await file.exists()) ? new Response(file) : new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
|
||||
plugins: [
|
||||
solid(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "patch-group-counters",
|
||||
enforce: "pre",
|
||||
load(id) {
|
||||
if (!process.env.PATCH_REVISION) return
|
||||
const root = fileURLToPath(new URL("../../../../..", import.meta.url))
|
||||
const file = path.relative(root, id).replaceAll("\\", "/")
|
||||
if (
|
||||
![
|
||||
"packages/session-ui/src/components/apply-patch-file.ts",
|
||||
"packages/session-ui/src/tools/tool-renderer.tsx",
|
||||
].includes(file)
|
||||
)
|
||||
return
|
||||
return execFileSync("git", ["show", `${process.env.PATCH_REVISION}:${file}`], { cwd: root, encoding: "utf8" })
|
||||
},
|
||||
transform(code, id) {
|
||||
if (process.env.PATCH_COUNTERS !== "1") return
|
||||
const functions = id.replaceAll("\\", "/").endsWith("/apply-patch-file.ts")
|
||||
? ["patchFileGroups"]
|
||||
: id.replaceAll("\\", "/").endsWith("/session-diff.ts")
|
||||
? ["normalize", "completePatchContents"]
|
||||
: id.replaceAll("\\", "/").endsWith("/diff/line.js")
|
||||
? ["diffLines"]
|
||||
: []
|
||||
for (const name of functions) {
|
||||
const pattern = new RegExp(`(export function ${name}\\([^)]*\\)[^{]*\\{)`)
|
||||
if (!pattern.test(code)) throw new Error(`Missing instrumented function ${name} in ${id}`)
|
||||
code = code.replace(pattern, `$1 performance.mark("patch-counter:${name}");`)
|
||||
}
|
||||
return functions.length ? { code, map: null } : undefined
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: { dedupe: ["solid-js", "@solidjs/meta"] },
|
||||
worker: { format: "es" },
|
||||
build: { outDir: process.env.PATCH_BUILD_DIR, emptyOutDir: true, sourcemap: true },
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
type BenchmarkRecord = {
|
||||
status?: string
|
||||
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
|
||||
}
|
||||
|
||||
export default class TabSwitchReporter implements Reporter {
|
||||
private output = ""
|
||||
private tests: TestCase[] = []
|
||||
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
|
||||
|
||||
onBegin(config: FullConfig, suite: Suite) {
|
||||
this.output = config.projects[0].outputDir
|
||||
this.tests = suite.allTests()
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
this.results.push({
|
||||
test,
|
||||
status: result.status,
|
||||
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("BENCHMARK "))
|
||||
.map((line) => line.slice("BENCHMARK ".length)),
|
||||
})
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult) {
|
||||
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
|
||||
try {
|
||||
await mkdir(this.output, { recursive: true })
|
||||
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
|
||||
} catch (error) {
|
||||
console.error("Could not save tab-switch benchmark records:", error)
|
||||
return { status: "failed" as const }
|
||||
}
|
||||
|
||||
console.log(`\nTab-switch benchmark: ${result.status}`)
|
||||
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
|
||||
const results = this.results.filter((entry) => entry.test.title === name)
|
||||
const unrun = this.tests.filter(
|
||||
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
|
||||
).length
|
||||
const records = results.flatMap((entry) =>
|
||||
entry.records.map((raw) => {
|
||||
try {
|
||||
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
|
||||
} catch {
|
||||
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
|
||||
const valid = passed
|
||||
.map((entry) => ({
|
||||
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
|
||||
stableObservedMs: entry.record?.metrics?.stableObservedMs,
|
||||
}))
|
||||
.filter(
|
||||
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
|
||||
typeof metrics.firstCorrectObservedMs === "number" &&
|
||||
Number.isFinite(metrics.firstCorrectObservedMs) &&
|
||||
typeof metrics.stableObservedMs === "number" &&
|
||||
Number.isFinite(metrics.stableObservedMs),
|
||||
)
|
||||
|
||||
console.log(`\n${name}`)
|
||||
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
|
||||
console.log(
|
||||
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
|
||||
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
|
||||
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
|
||||
)
|
||||
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
|
||||
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
|
||||
if (values.length === 0) {
|
||||
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
|
||||
return
|
||||
}
|
||||
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
|
||||
const p95 = values[Math.ceil(values.length * 0.95) - 1]
|
||||
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
|
||||
})
|
||||
})
|
||||
console.log(`\nRaw BENCHMARK records: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
function counts(statuses: string[]) {
|
||||
return (
|
||||
Array.from(new Set(statuses))
|
||||
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
|
||||
.join(", ") || "none"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { fixture } from "./timeline/session-timeline-stress.fixture"
|
||||
import { messages } from "./timeline/session-tab-switch.fixture"
|
||||
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
|
||||
|
||||
const args = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
|
||||
})
|
||||
const directory = path.resolve(args.values.dist)
|
||||
const api = createMockServerHandler({
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
|
||||
vcsDiff: createReviewDiffs(),
|
||||
})
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(args.values.port),
|
||||
idleTimeout: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
|
||||
)
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const response = await api.handler(request)
|
||||
response.headers.set("cache-control", "no-store")
|
||||
return response
|
||||
}
|
||||
const file = Bun.file(path.join(directory, url.pathname))
|
||||
if (!url.pathname.endsWith("/") && (await file.exists())) {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
|
||||
},
|
||||
})
|
||||
}
|
||||
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
|
||||
},
|
||||
})
|
||||
console.log(`Tab fixture: ${server.url} (${directory})`)
|
||||
const close = async () => {
|
||||
await server.stop(true)
|
||||
await api.dispose()
|
||||
}
|
||||
process.once("SIGINT", close)
|
||||
process.once("SIGTERM", close)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Native Terminal Benchmark
|
||||
|
||||
Manual Windows benchmark. Run only in an isolated development worktree. It does
|
||||
not connect to an OpenCode service, user profile, or database.
|
||||
|
||||
Build from `packages/app` with
|
||||
`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze
|
||||
`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to
|
||||
an unused loopback URL, `TERMINAL_BUILD` to the frozen build,
|
||||
`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS`
|
||||
to an external result directory. Run:
|
||||
|
||||
```sh
|
||||
bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
The runner owns its preview server and each test owns a PowerShell ConPTY process.
|
||||
Session metadata is deterministic. Native output is forwarded through Playwright's
|
||||
WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas,
|
||||
and serializer. No output is dropped, paused, or delayed. This is native terminal
|
||||
plus production renderer evidence, not the production PTY backend or Electron IPC.
|
||||
|
||||
The workload is 12,000 colored build/test log lines with file paths, durations, and
|
||||
result descriptions. Cases separate visible output, the same output while hidden,
|
||||
and closing the session tab after filling the configured scrollback. Ghostty
|
||||
converts the app's 10,000-line setting to bytes at its initial 80-column width;
|
||||
resizing reduces the effective row capacity. The report records actual retained
|
||||
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
|
||||
requires the final marker in Ghostty and completion of its write callbacks, not
|
||||
just WebSocket delivery. Teardown requires Home readiness and the final serialized
|
||||
snapshot. Input, focus, resizing, and native process survival are checked.
|
||||
|
||||
`probe.ts` is included only by this benchmark build. It observes actual writes,
|
||||
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
|
||||
time, not total process CPU or RAM. For attribution, set
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing.
|
||||
`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden
|
||||
rendering; do not mix these instrumented samples with clean timing.
|
||||
Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts.
|
||||
`TERMINAL_SCREENSHOTS` captures the visible result after timing.
|
||||
|
||||
The benchmark has no machine-dependent performance thresholds. Keep raw logs,
|
||||
snapshots, traces, and screenshots outside Git. Run heavy work through the
|
||||
coordinator's exclusive gate when participating in a shared performance wave.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import config from "../../../playwright.config"
|
||||
|
||||
export default defineConfig({
|
||||
...config,
|
||||
testDir: ".",
|
||||
testIgnore: [],
|
||||
testMatch: "terminal-benchmark.spec.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 120_000,
|
||||
outputDir: process.env.TERMINAL_RESULTS,
|
||||
reporter: [["line"]],
|
||||
webServer: {
|
||||
command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`,
|
||||
url: process.env.PLAYWRIGHT_BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" },
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Terminal } from "ghostty-web"
|
||||
import { SerializeAddon } from "../../../src/session/terminal/serialize"
|
||||
|
||||
export type TerminalProbe = {
|
||||
term?: Terminal
|
||||
writes: number
|
||||
pending: number
|
||||
bytes: number
|
||||
renders: number
|
||||
hiddenRenders: number
|
||||
draws: number
|
||||
hiddenDraws: number
|
||||
serialized: { ms: number; bytes: number; value: string }[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
terminalProbe: TerminalProbe
|
||||
}
|
||||
}
|
||||
|
||||
const probe: TerminalProbe = {
|
||||
writes: 0,
|
||||
pending: 0,
|
||||
bytes: 0,
|
||||
renders: 0,
|
||||
hiddenRenders: 0,
|
||||
draws: 0,
|
||||
hiddenDraws: 0,
|
||||
serialized: [],
|
||||
}
|
||||
window.terminalProbe = probe
|
||||
const open = Terminal.prototype.open
|
||||
Terminal.prototype.open = function (element) {
|
||||
probe.term = this
|
||||
open.call(this, element)
|
||||
// Ghostty does not expose render events. This benchmark-only wrapper observes its
|
||||
// actual renderer; it does not alter scheduling, parsing, or drawing.
|
||||
const renderer = (this as unknown as { renderer: { render: (...args: unknown[]) => void } }).renderer
|
||||
const render = renderer.render
|
||||
let hidden = false
|
||||
renderer.render = function (...args) {
|
||||
probe.renders++
|
||||
hidden = !element.checkVisibility()
|
||||
if (hidden) probe.hiddenRenders++
|
||||
return render.apply(this, args)
|
||||
}
|
||||
if (new URL(location.href).searchParams.has("terminalDrawProbe")) {
|
||||
const context = element.querySelector("canvas")!.getContext("2d")!
|
||||
const draw = context.drawImage
|
||||
context.drawImage = function (...args: unknown[]) {
|
||||
probe.draws++
|
||||
if (hidden) probe.hiddenDraws++
|
||||
Reflect.apply(draw, this, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
const write = Terminal.prototype.write
|
||||
Terminal.prototype.write = function (data, done) {
|
||||
probe.writes++
|
||||
probe.pending++
|
||||
probe.bytes += typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength
|
||||
return write.call(this, data, () => {
|
||||
probe.pending--
|
||||
done?.()
|
||||
})
|
||||
}
|
||||
const serialize = SerializeAddon.prototype.serialize
|
||||
SerializeAddon.prototype.serialize = function (options) {
|
||||
const start = performance.now()
|
||||
const value = serialize.call(this, options)
|
||||
probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value })
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
param([Parameter(Mandatory = $true)][string]$Fixture)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::WriteLine('TERMINAL_FIXTURE_READY')
|
||||
while ($null -ne ($command = [Console]::ReadLine())) {
|
||||
if ($command -eq 'exit') { exit 0 }
|
||||
if ($command -eq 'run') {
|
||||
foreach ($line in [System.IO.File]::ReadLines($Fixture)) {
|
||||
[Console]::WriteLine($line)
|
||||
}
|
||||
[Console]::WriteLine('TERMINAL_WORKLOAD_DONE')
|
||||
}
|
||||
if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') }
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import type {} from "./probe"
|
||||
|
||||
// Use the same installed native PTY package as Core, with a fixture-owned process.
|
||||
const native = createRequire(new URL("../../../../core/package.json", import.meta.url))("@lydell/node-pty") as {
|
||||
spawn: (
|
||||
file: string,
|
||||
args: string[],
|
||||
options: { cols: number; rows: number; cwd: string },
|
||||
) => {
|
||||
pid: number
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
kill: () => void
|
||||
onData: (handler: (data: string) => void) => { dispose: () => void }
|
||||
onExit: (handler: () => void) => { dispose: () => void }
|
||||
}
|
||||
}
|
||||
|
||||
const sessionID = "ses_terminal_benchmark"
|
||||
const ptyID = "pty_terminal_benchmark"
|
||||
const title = "Terminal build output"
|
||||
const server = process.env.PLAYWRIGHT_BASE_URL!
|
||||
const href = `/server/${Buffer.from(server).toString("base64url")}/session/${sessionID}`
|
||||
const lines = Array.from({ length: 12_000 }, (_, i) => {
|
||||
const unit = ["session/history", "session/runner", "project/discovery", "tool/shell", "provider/stream"][i % 5]
|
||||
return `\x1b[32mPASS\x1b[0m packages/core/test/${unit}-${String(i).padStart(5, "0")}.test.ts \x1b[2m[${10 + (i % 237)}ms]\x1b[0m validates ordered output and durable recovery`
|
||||
}).join("\r\n")
|
||||
|
||||
benchmark.use({ traceScope: "interaction", viewport: { width: 1440, height: 900 } })
|
||||
|
||||
for (const scenario of ["visible-output", "hidden-output", "full-scrollback-teardown"] as const) {
|
||||
benchmark(scenario, async ({ page, report }, info) => {
|
||||
const dir = await mkdtemp(path.join(process.env.TERMINAL_ARTIFACTS ?? tmpdir(), "terminal-fixture-"))
|
||||
await writeFile(path.join(dir, "build.log"), lines)
|
||||
const pty = native.spawn(
|
||||
"pwsh.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-File",
|
||||
fileURLToPath(new URL("./shell.ps1", import.meta.url)),
|
||||
"-Fixture",
|
||||
path.join(dir, "build.log"),
|
||||
],
|
||||
{ cols: 120, rows: 24, cwd: dir },
|
||||
)
|
||||
const exited = new Promise<void>((resolve) => pty.onExit(resolve))
|
||||
let output = ""
|
||||
let connected = 0
|
||||
let closed = 0
|
||||
let send: ((data: string) => void) | undefined
|
||||
const listener = pty.onData((data) => {
|
||||
output += data
|
||||
send?.(data)
|
||||
})
|
||||
const sizes: { cols: number; rows: number }[] = []
|
||||
const removals: string[] = []
|
||||
try {
|
||||
if (process.env.TERMINAL_DRAW_PROBE) {
|
||||
await page.addInitScript(() => {
|
||||
const fill = CanvasRenderingContext2D.prototype.fillText
|
||||
CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters<typeof fill>) {
|
||||
if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-component="terminal"]')) {
|
||||
window.terminalProbe.draws++
|
||||
if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++
|
||||
}
|
||||
Reflect.apply(fill, this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
const location = { directory: dir, project: { id: "proj_terminal_benchmark", directory: dir } }
|
||||
const data = {
|
||||
id: ptyID,
|
||||
title: "Terminal 1",
|
||||
command: "pwsh.exe",
|
||||
args: [],
|
||||
cwd: dir,
|
||||
status: "running",
|
||||
pid: pty.pid,
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: dir,
|
||||
project: {
|
||||
id: location.project.id,
|
||||
worktree: dir,
|
||||
vcs: "git",
|
||||
name: "terminal-benchmark",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID: location.project.id,
|
||||
directory: dir,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty**", async (route) => {
|
||||
if (route.request().method() === "DELETE") removals.push(route.request().url())
|
||||
const body = route.request().postDataJSON()
|
||||
if (body?.size) {
|
||||
sizes.push(body.size)
|
||||
pty.resize(body.size.cols, body.size.rows)
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location,
|
||||
data: route.request().url().includes("connect-token") ? { ticket: "fixture", expires_in: 60 } : data,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (socket) => {
|
||||
connected++
|
||||
send = (data) => socket.send(data)
|
||||
socket.send(output.slice(Number(new URL(socket.url()).searchParams.get("cursor") ?? 0)))
|
||||
socket.onMessage((data) => pty.write(String(data)))
|
||||
socket.onClose(() => {
|
||||
closed++
|
||||
send = undefined
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID },
|
||||
)
|
||||
await page.goto(
|
||||
`${href}${process.env.TERMINAL_DRAW_PROBE && scenario !== "full-scrollback-teardown" ? "?terminalDrawProbe" : ""}`,
|
||||
)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await waitForText(page, "TERMINAL_FIXTURE_READY")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const size = await page.evaluate(() => ({
|
||||
cols: window.terminalProbe.term!.cols,
|
||||
rows: window.terminalProbe.term!.rows,
|
||||
}))
|
||||
return sizes.at(-1)?.cols === size.cols && sizes.at(-1)?.rows === size.rows
|
||||
})
|
||||
.toBe(true)
|
||||
if (scenario === "hidden-output") {
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeHidden()
|
||||
}
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
const start = await page.evaluate(() => {
|
||||
window.terminalProbe.renders = 0
|
||||
window.terminalProbe.hiddenRenders = 0
|
||||
window.terminalProbe.draws = 0
|
||||
window.terminalProbe.hiddenDraws = 0
|
||||
return performance.now()
|
||||
})
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
// The producer is not throttled. The visible and hidden cases receive the same bytes.
|
||||
pty.write("run\r")
|
||||
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
|
||||
const produced = await page.evaluate(
|
||||
(start) => ({
|
||||
ms: performance.now() - start,
|
||||
renders: window.terminalProbe.renders,
|
||||
hiddenRenders: window.terminalProbe.hiddenRenders,
|
||||
draws: window.terminalProbe.draws,
|
||||
hiddenDraws: window.terminalProbe.hiddenDraws,
|
||||
bytes: window.terminalProbe.bytes,
|
||||
scrollback: window.terminalProbe.term!.getScrollbackLength(),
|
||||
cols: window.terminalProbe.term!.cols,
|
||||
rows: window.terminalProbe.term!.rows,
|
||||
firstRecord: Number(
|
||||
window.terminalProbe
|
||||
.term!.buffer.normal.getLine(0)
|
||||
?.translateToString(true)
|
||||
.match(/-(\d{5})\.test\.ts/)?.[1],
|
||||
),
|
||||
}),
|
||||
start,
|
||||
)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const cpuMs =
|
||||
(after.metrics.find((x) => x.name === "TaskDuration")!.value -
|
||||
before.metrics.find((x) => x.name === "TaskDuration")!.value) *
|
||||
1000
|
||||
let interaction: Record<string, unknown> = {}
|
||||
if (scenario === "hidden-output") {
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
|
||||
interaction = { returnMs: await page.evaluate((start) => performance.now() - start, start) }
|
||||
}
|
||||
if (scenario === "full-scrollback-teardown") {
|
||||
// Ghostty converts the configured line limit to bytes at the initial
|
||||
// 80-column size. Resizing changes the effective retained row count.
|
||||
expect(produced.scrollback).toBeGreaterThan(0)
|
||||
expect(produced.firstRecord).toBeGreaterThan(0)
|
||||
expect(produced.firstRecord).toBeLessThan(11_999)
|
||||
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
|
||||
await expect(close).toBeVisible()
|
||||
const cpuBefore = await cdp.send("Performance.getMetrics")
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await close.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.locator('[data-component="home-session-search"]')).toBeVisible()
|
||||
await expect(page.locator('[data-component="home-session-search"] input')).toBeEditable()
|
||||
await expect.poll(() => page.evaluate(() => window.terminalProbe.serialized.length)).toBe(1)
|
||||
interaction = await page.evaluate(
|
||||
(start) => ({
|
||||
homeReadyMs: performance.now() - start,
|
||||
serializeMs: window.terminalProbe.serialized[0].ms,
|
||||
serializedBytes: window.terminalProbe.serialized[0].bytes,
|
||||
}),
|
||||
start,
|
||||
)
|
||||
const cpuAfter = await cdp.send("Performance.getMetrics")
|
||||
interaction.teardownCpuMs =
|
||||
(cpuAfter.metrics.find((x) => x.name === "TaskDuration")!.value -
|
||||
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
|
||||
1000
|
||||
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
|
||||
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
|
||||
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
|
||||
)
|
||||
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
|
||||
await writeFile(
|
||||
path.join(
|
||||
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
|
||||
`${process.env.TERMINAL_BUNDLE}-${info.repeatEachIndex}.ansi`,
|
||||
),
|
||||
snapshot,
|
||||
)
|
||||
await expect(terminal).toHaveCount(0)
|
||||
expect(closed).toBe(1)
|
||||
// UI teardown must not terminate the native process.
|
||||
pty.write("ping\r")
|
||||
await expect.poll(() => output.includes("TERMINAL_PROCESS_ALIVE")).toBe(true)
|
||||
}
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
expect(connected).toBe(1)
|
||||
expect(removals).toEqual([])
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
report(
|
||||
{ ...produced, cpuMs, ...interaction },
|
||||
{
|
||||
revision: process.env.TERMINAL_REVISION,
|
||||
bundle: process.env.TERMINAL_BUNDLE,
|
||||
fixtureBytes: Buffer.byteLength(lines),
|
||||
fixtureLines: 12_000,
|
||||
transport: "Windows ConPTY -> Playwright WebSocket fixture -> production Terminal/writer/Ghostty",
|
||||
scope: "Chromium renderer; not Electron total RAM or production backend IPC",
|
||||
},
|
||||
)
|
||||
if (scenario !== "full-scrollback-teardown") {
|
||||
// Validate input, focus, and resize after both visible and hidden output.
|
||||
await terminal.click()
|
||||
await expect(terminal.locator("textarea")).toBeFocused()
|
||||
await page.keyboard.type("ping")
|
||||
await page.keyboard.press("Enter")
|
||||
await waitForText(page, "TERMINAL_PROCESS_ALIVE")
|
||||
const columns = await page.evaluate(() => window.terminalProbe.term!.cols)
|
||||
await page.setViewportSize({ width: 1100, height: 800 })
|
||||
await expect.poll(() => page.evaluate(() => window.terminalProbe.term!.cols)).not.toBe(columns)
|
||||
await expect
|
||||
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
|
||||
.toBe(true)
|
||||
expect(closed).toBe(0)
|
||||
}
|
||||
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
|
||||
await page.screenshot({
|
||||
path: path.join(process.env.TERMINAL_SCREENSHOTS, `${scenario}-${info.repeatEachIndex}.png`),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
listener.dispose()
|
||||
try {
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
// Stop fixture request handlers before killing their native resource. The
|
||||
// app debounces PTY resize requests independently of the canvas resize.
|
||||
await page.unrouteAll({ behavior: "wait" })
|
||||
await page.close()
|
||||
} finally {
|
||||
pty.kill()
|
||||
await exited
|
||||
await writeFile(
|
||||
path.join(
|
||||
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
|
||||
`${process.env.TERMINAL_BUNDLE}-${scenario}-${info.repeatEachIndex}.native.log`,
|
||||
),
|
||||
output,
|
||||
)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForText(page: Page, text: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((text) => {
|
||||
const probe = window.terminalProbe
|
||||
const term = probe?.term
|
||||
if (!term || probe.pending !== 0) return false
|
||||
const buffer = term.buffer.active
|
||||
return Array.from(
|
||||
{ length: term.rows },
|
||||
(_, i) => buffer.getLine(buffer.length - term.rows + i)?.translateToString(true) ?? "",
|
||||
)
|
||||
.join("\n")
|
||||
.includes(text)
|
||||
}, text),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mergeConfig } from "vite"
|
||||
import config from "../../../vite.config"
|
||||
|
||||
// The probe is included only in this manual benchmark build, never in the app build.
|
||||
export default mergeConfig(config, {
|
||||
plugins: [
|
||||
{
|
||||
name: "terminal-benchmark-probe",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: () => [
|
||||
{ tag: "script", attrs: { type: "module", src: "/e2e/performance/terminals/probe.ts" }, injectTo: "head" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
const sessionID = "ses_composer_write_batch"
|
||||
const title = "Composer persistence workload"
|
||||
const addition = " Keep the existing error handling and add coverage."
|
||||
const text =
|
||||
Array.from(
|
||||
{ length: 180 },
|
||||
(_, index) =>
|
||||
`Review requirement ${index + 1}: preserve request ordering in src/queue/worker-${index % 12}.ts. ` +
|
||||
`A failed request must retain its payload, report its cause, and remain safe to retry.\n` +
|
||||
`Expected: await queue.flush(); expect(await repository.read(id)).toEqual(accepted);\n`,
|
||||
).join("") + "Implementation notes:"
|
||||
const items = Array.from({ length: 8 }, (_, index) => ({
|
||||
type: "file",
|
||||
path: `src/queue/worker-${index}.ts`,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 24, endChar: 0 },
|
||||
commentID: `composer-write-batch-${index}`,
|
||||
comment: `Check retry path ${index}: keep the original request identity and error cause.`,
|
||||
preview: Array.from(
|
||||
{ length: 24 },
|
||||
(_, line) => ` const request${line} = await repository.loadPending("queue-${index}");`,
|
||||
).join("\n"),
|
||||
}))
|
||||
const document = {
|
||||
prompt: [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text.length,
|
||||
mode: "normal",
|
||||
context: { items },
|
||||
}
|
||||
type Probe = { active: boolean; encodes: number; bytes: number; inputs: number; keyups: number }
|
||||
type ProbeWindow = typeof window & { composerWriteBatch: Probe }
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
video: "off",
|
||||
trace: "off",
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
})
|
||||
|
||||
for (const scenario of ["typing", "cursor-movement", "cursor-noop", "submit-cleanup"] as const) {
|
||||
benchmark(`composer-write-batch: ${scenario}`, async ({ page, report }, testInfo) => {
|
||||
const submitted: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ ...fixture.sessions[0], id: sessionID, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => submitted.push(input.body),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ key, value, counts }) => {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
const probe: Probe = { active: false, encodes: 0, bytes: 0, inputs: 0, keyups: 0 }
|
||||
;(window as ProbeWindow).composerWriteBatch = probe
|
||||
// The draft adapter parses each schema-encoded composer document once before
|
||||
// its asynchronous blob walk. Count at this boundary, not at the IDB write
|
||||
// (which already discards superseded writes). This fixture is ASCII only.
|
||||
if (counts) {
|
||||
const parse = JSON.parse
|
||||
JSON.parse = (value, reviver) => {
|
||||
if (probe.active && typeof value === "string" && value.startsWith('{"prompt":[')) {
|
||||
probe.encodes++
|
||||
probe.bytes += value.length
|
||||
}
|
||||
return parse(value, reviver)
|
||||
}
|
||||
}
|
||||
window.addEventListener("input", (event) => {
|
||||
if (
|
||||
probe.active &&
|
||||
event.target instanceof Element &&
|
||||
event.target.matches('[data-component="composer-editor"]')
|
||||
)
|
||||
probe.inputs++
|
||||
})
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (
|
||||
probe.active &&
|
||||
event.target instanceof Element &&
|
||||
event.target.matches('[data-component="composer-editor"]')
|
||||
)
|
||||
probe.keyups++
|
||||
})
|
||||
},
|
||||
{
|
||||
key: `${base64Encode(fixture.directory)}/prompt/${sessionID}.v2`,
|
||||
value: document,
|
||||
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
|
||||
},
|
||||
)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(editor).toHaveText(text)
|
||||
await editor.focus()
|
||||
await editor.press("ControlOrMeta+End")
|
||||
await page.evaluate(() => window.document.fonts.ready)
|
||||
const stored = async () =>
|
||||
page.evaluate(async (sessionID) => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
try {
|
||||
const transaction = db.transaction("documents")
|
||||
const keys = transaction.objectStore("documents").getAllKeys()
|
||||
const values = transaction.objectStore("documents").getAll()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
const index = keys.result.findIndex((key) => String(key).endsWith(`session:${sessionID}:prompt`))
|
||||
// Parse after disabling the count so the observation is not part of it.
|
||||
const probe = (window as ProbeWindow).composerWriteBatch
|
||||
const active = probe.active
|
||||
probe.active = false
|
||||
const value = index < 0 ? undefined : JSON.parse(values.result[index])
|
||||
probe.active = active
|
||||
return value as { prompt: { content: string }[]; cursor: number; context: { items: unknown[] } } | undefined
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}, sessionID)
|
||||
await expect.poll(async () => (await stored())?.cursor).toBe(text.length)
|
||||
expect((await stored())?.context.items).toHaveLength(items.length)
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).composerWriteBatch.active = true
|
||||
performance.mark("composer-write-batch-start")
|
||||
})
|
||||
const start = performance.now()
|
||||
if (scenario === "typing") await editor.pressSequentially(addition)
|
||||
if (scenario === "cursor-movement") await editor.press("ArrowLeft")
|
||||
if (scenario === "cursor-noop") await editor.press("ArrowRight")
|
||||
if (scenario === "submit-cleanup") await editor.press("Enter")
|
||||
const expectedText = scenario === "typing" ? text + addition : scenario === "submit-cleanup" ? "" : text
|
||||
const expectedCursor =
|
||||
scenario === "typing"
|
||||
? text.length + addition.length
|
||||
: scenario === "submit-cleanup"
|
||||
? 0
|
||||
: text.length - Number(scenario === "cursor-movement")
|
||||
await expect(editor).toHaveText(expectedText)
|
||||
await expect.poll(async () => (await stored())?.cursor).toBe(expectedCursor)
|
||||
const elapsedMs = performance.now() - start
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const probe = await page.evaluate(() => {
|
||||
performance.mark("composer-write-batch-end")
|
||||
const probe = (window as ProbeWindow).composerWriteBatch
|
||||
probe.active = false
|
||||
return probe
|
||||
})
|
||||
expect((await stored())?.prompt.map((part) => part.content).join("")).toBe(expectedText)
|
||||
if (scenario === "submit-cleanup") {
|
||||
await expect.poll(() => submitted.length).toBe(1)
|
||||
expect(submitted[0].text).toContain(text)
|
||||
expect((await stored())?.context.items).toHaveLength(0)
|
||||
}
|
||||
expect(probe.keyups).toBe(scenario === "typing" ? addition.length : 1)
|
||||
expect(probe.inputs).toBe(scenario === "typing" ? addition.length : 0)
|
||||
const metric = (name: string) =>
|
||||
1000 *
|
||||
((after.metrics.find((x) => x.name === name)?.value ?? 0) -
|
||||
(before.metrics.find((x) => x.name === name)?.value ?? 0))
|
||||
report(
|
||||
{ elapsedMs, taskMs: metric("TaskDuration"), scriptMs: metric("ScriptDuration"), ...probe },
|
||||
{
|
||||
scenario,
|
||||
promptBytes: Buffer.byteLength(text),
|
||||
contextItems: items.length,
|
||||
persistedBytes: Buffer.byteLength(JSON.stringify(document)),
|
||||
typedCharacters: scenario === "typing" ? addition.length : 0,
|
||||
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
|
||||
browser: page.context().browser()!.version(),
|
||||
build: process.env.OPENCODE_PERSISTENCE_BUILD,
|
||||
transport: "playwright-route",
|
||||
completion: "editor text and committed IDB cursor",
|
||||
},
|
||||
)
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
await cdp.detach()
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${scenario}.png`) })
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user