Compare commits

..

5 Commits

Author SHA1 Message Date
Aiden Cline ab03b05c00 fix(ai): replay function call item ids 2026-08-07 22:30:52 -05:00
Aiden Cline 0f67def34d fix(ai): keep response ids provider-owned 2026-08-07 17:15:00 -05:00
Aiden Cline 0657dcbad2 fix(ai): stop generating response item ids 2026-08-07 14:26:50 -05:00
Aiden Cline 82afcfd4e0 fix(ai): generate uuidv7 item ids 2026-08-07 14:04:15 -05:00
Aiden Cline a1cbcc8641 fix(ai): preserve responses item ids 2026-08-07 12:57:48 -05:00
1068 changed files with 33167 additions and 107923 deletions
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- dev
- v2
jobs:
generate:
+6 -2
View File
@@ -281,6 +281,7 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
@@ -377,14 +378,16 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@@ -402,7 +405,8 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_KEYCHAIN: build.keychain
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+1
View File
@@ -251,3 +251,4 @@ opencode-drive stop --name demo
```bash
opencode-drive dir --name demo
```
@@ -1,63 +0,0 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
+27
View File
@@ -252,6 +252,33 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:
+43 -95
View File
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -220,7 +220,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -256,7 +256,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -283,7 +283,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
@@ -300,7 +300,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -324,7 +324,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -388,7 +388,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.11.1",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
@@ -424,16 +424,16 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"marked": "^15",
},
"devDependencies": {
"@actions/artifact": "4.0.0",
@@ -478,7 +478,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -492,7 +492,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -525,7 +525,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -541,7 +541,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.101",
},
@@ -571,23 +571,9 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
@@ -693,7 +679,6 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -703,7 +688,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
@@ -718,11 +703,15 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -735,6 +724,7 @@
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -765,7 +755,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/sdk": "1.18.5",
"@slack/bolt": "^3.17.1",
@@ -778,7 +768,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -812,7 +802,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -831,7 +821,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -891,7 +881,6 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
@@ -922,7 +911,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -938,6 +927,7 @@
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
@@ -947,7 +937,6 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1019,7 +1008,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.15",
"version": "1.18.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1074,18 +1063,16 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1143,7 +1130,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "18.0.7",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -1153,7 +1140,6 @@
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
@@ -1526,18 +1512,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1746,10 +1720,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1844,8 +1814,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -2096,8 +2064,6 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -3332,8 +3298,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3574,10 +3538,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -3652,7 +3612,7 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
@@ -4190,7 +4150,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4610,8 +4570,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4664,7 +4622,9 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
@@ -4836,8 +4796,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4876,10 +4834,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5464,8 +5418,6 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"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=="],
@@ -5680,8 +5632,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6380,6 +6330,8 @@
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/session-ui/@opencode-ai/sdk": ["@opencode-ai/sdk@../app/vendor/opencode-ai-sdk-1.18.8-dev.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-C2nfk4x0sPINwE5V6DPkFSuH3PkUmKPWHPzxpXC1j+3Ui5hslLCWJbkk8WcOG1Lyt3C0+yp4ea64v/kmtYCO4w=="],
@@ -6620,6 +6572,8 @@
"blume/katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="],
"blume/marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"blume/node-html-parser": ["node-html-parser@9.0.0", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-MhdaHPyxnyYu/sf0TpiRvDnTrkum0UKHC7FdbDGIUQNlx3I7xzwXoyV0eMUMv/XU+lkJT1glOUzpDPq7b2p1Ew=="],
"blume/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -6636,8 +6590,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6754,8 +6706,6 @@
"jszip/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=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lazystream/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=="],
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -6784,8 +6734,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -6886,8 +6834,12 @@
"terser/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -7710,8 +7662,6 @@
"blume/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"blume/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"blume/node-html-parser/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"blume/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -7784,8 +7734,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
+2 -6
View File
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
}
}
+1 -4
View File
@@ -78,7 +78,7 @@
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "18.0.7",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
@@ -100,7 +100,6 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
@@ -158,8 +157,6 @@
"effect": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+3 -4
View File
@@ -368,12 +368,11 @@ Other provider exports listed above remain direct facades until they explicitly
## Provider options & HTTP overlays
Request options in order of stability:
Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it.
3. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `store`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
4. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Route/provider defaults are overridden by request-level values for each axis.
+5 -4
View File
@@ -33,10 +33,9 @@ const model = OpenAI.configure({
//
// - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences.
// - `promptCacheKey`: stable cache affinity for protocols that support it.
// - `providerOptions`: namespaced provider-native behavior. For example,
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
// OpenRouter routing/reasoning.
// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
// config, or OpenRouter routing/reasoning.
// - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable.
//
@@ -46,7 +45,9 @@ const request = LLM.request({
system: "You are concise and practical.",
prompt: "Tell me a joke",
generation: { maxTokens: 80, temperature: 0.7 },
promptCacheKey: "tutorial-joke",
providerOptions: {
openai: { promptCacheKey: "tutorial-joke" },
},
})
// 3. `generate` sends the request and collects the event stream into one
+1 -1
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"
import * as path from "node:path"
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
const MODELS_DEV_URL = "https://models.opencode.ai/api.json"
const MODELS_DEV_URL = "https://models.dev/api.json"
type JsonRecord = Record<string, unknown>
+1 -2
View File
@@ -133,8 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
@@ -422,12 +422,14 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content) {
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: Tool.Content,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
})
const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart) {
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
// Text / json / error results stay as a string for backward compatibility
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
+129 -68
View File
@@ -90,10 +90,15 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
])
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({
role: Schema.tag("user"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesInputContent),
}),
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
@@ -101,19 +106,23 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
type LoweredInputItem =
| OpenResponsesInputItem
| ProviderInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +137,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -254,6 +263,11 @@ export interface Extension {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
readonly lowerProviderItem?: (
part: ToolResultPart,
providerMetadataKey: string,
store: boolean | undefined,
) => ProviderInputItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -310,36 +324,48 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadataItemID = (
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
providerMetadataKey: string,
) => {
if (part.itemId) return part.itemId
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const itemId = metadataItemID(part, providerMetadataKey)
return {
type: "function_call",
...(itemId === undefined ? {} : { id: itemId }),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
@@ -358,7 +384,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerUserContent = Effect.fnUntraced(function* (
const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
@@ -370,7 +396,7 @@ const lowerUserContent = Effect.fnUntraced(function* (
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
item: Content,
request: LLMRequest,
extension: Extension,
@@ -383,7 +409,7 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
@@ -397,17 +423,18 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const options = OpenResponsesOptions.resolve(request)
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const store = options.store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user")
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
@@ -427,24 +454,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const hostedToolItems = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
const groups = content.reduce<
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const itemId = metadataItemID(part, providerMetadataKey)
const group = groups.at(-1)
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
else groups.push({ phase, itemId, parts: [part] })
return groups
}, [])
input.push(
...groups.map((group) => ({
role: "assistant" as const,
...(group.itemId === undefined ? {} : { id: group.itemId }),
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -460,11 +487,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -474,6 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
const replay = {
type: reasoning.type,
id: reasoning.id,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
@@ -484,22 +507,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
if (!providerItem && store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolReferences.add(itemID)
if (itemID) hostedToolItems.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -539,7 +564,7 @@ const lowerOptions = (request: LLMRequest) => {
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -641,9 +666,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -652,7 +677,13 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -663,7 +694,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
},
events,
]
@@ -705,7 +743,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${item.id}:0`,
reasoningMetadata(state, item),
item.id,
),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -724,6 +768,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
@@ -731,7 +776,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
},
[
...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
],
]
}
@@ -750,6 +800,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -770,6 +821,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
),
state.lifecycle,
)
@@ -781,6 +833,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -816,6 +869,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
)
: state.lifecycle,
reasoningItems: {
@@ -870,7 +924,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
),
messageItems,
messagePhases,
@@ -881,9 +936,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
itemId: item.id,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -913,7 +974,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -921,12 +982,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
events,
] satisfies StepResult
}
+17 -20
View File
@@ -132,7 +132,6 @@ export const bodyFields = {
stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
@@ -371,13 +370,12 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = {
role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(details !== undefined ? { reasoning_details: details } : {}),
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
@@ -511,7 +509,6 @@ const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
return {
...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
}
@@ -710,20 +707,23 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
Boolean(delta?.content) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
const reasoningField = state.reasoningField ?? reasoning?.field
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
@@ -749,7 +749,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index =
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index]
const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
@@ -812,18 +813,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const toolCallEvents =
state.finishReason === undefined && Object.keys(state.tools).length > 0
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason
? {
...state.finishReason,
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
: undefined
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -833,9 +830,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
}
+32 -3
View File
@@ -38,10 +38,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
])
const OpenAIResponsesCoreFields = {
@@ -80,6 +84,25 @@ const extension = {
mime_type: media.mime,
}
},
lowerProviderItem: (part, providerMetadataKey, store) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
if (typeof metadata.item.type !== "string") return undefined
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
// The public API requires stored state to replay image-generation items. In
// stateless mode, lower the generated file through the existing user-image fallback.
if (metadata.item.type === "image_generation_call" && store === false) return undefined
if (metadata.item.type === "image_generation_call")
return {
type: metadata.item.type,
...(id === undefined ? {} : { id }),
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
}
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
return item
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -195,23 +218,29 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(
state,
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
)
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
itemId: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
providerMetadata: callMetadata,
}),
LLMEvent.toolResult({
id: item.id,
itemId: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
providerMetadata: resultMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
+40 -12
View File
@@ -1,4 +1,10 @@
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
export interface State {
readonly stepStarted: boolean
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
export const textStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata }))
events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = textStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started
}
@@ -32,10 +51,11 @@ export const reasoningStart = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
@@ -45,9 +65,10 @@ export const reasoningDelta = (
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started
}
@@ -56,19 +77,26 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata }))
events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
@@ -33,6 +33,7 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export interface Resolved {
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
@@ -49,6 +50,7 @@ export const resolve = (request: LLMRequest): Resolved => {
return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined,
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
+23 -2
View File
@@ -1,5 +1,12 @@
import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import {
AIError,
LLMEvent,
type ProviderMetadata,
type ResponseItemID,
type ToolCall,
type ToolInputError,
} from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -10,6 +17,7 @@ type StreamKey = string | number
* so far, not the parsed object.
*/
export interface PendingTool extends ToolAccumulator {
readonly itemId?: ResponseItemID
readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata
}
@@ -52,6 +60,7 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
@@ -60,6 +69,7 @@ const inputStart = (tool: PendingTool) =>
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
text,
})
@@ -70,6 +80,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
@@ -82,6 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
raw,
}),
@@ -93,7 +105,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
: [
LLMEvent.toolInputEnd({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerMetadata: tool.providerMetadata,
}),
event,
]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -148,6 +168,7 @@ export const appendOrStart = <K extends StreamKey>(
id,
name,
input: `${current?.input ?? ""}${delta.text}`,
itemId: current?.itemId,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
}
@@ -0,0 +1,71 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly.
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses"
const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model)
if (!match) return false
return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini")
}
export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest
}
const configuredResponsesRoute = (options: LanguageModelOptions) =>
responsesRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
const configuredChatRoute = (options: LanguageModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: LanguageModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(modelID, defaults(options)))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: (modelID: string | ModelID) =>
shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
responses,
chat,
configure,
}
}
export const provider = {
id,
configure,
}
+1
View File
@@ -5,6 +5,7 @@ export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google"
export * as GoogleVertex from "./google-vertex"
export * as GoogleVertexChat from "./google-vertex-chat"
@@ -5,6 +5,7 @@ export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown
readonly instructions?: string
readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
@@ -17,6 +17,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
const openai = Object.fromEntries(
definedEntries({
store: options?.store,
promptCacheKey: options?.promptCacheKey,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
+2 -1
View File
@@ -55,6 +55,7 @@ export interface OpenRouterOptions {
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
@@ -121,7 +122,6 @@ export const protocol = Protocol.make({
...body,
messages,
...bodyOptions(request.providerOptions?.openrouter),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody
}),
),
@@ -161,6 +161,7 @@ const bodyOptions = (input: unknown) => {
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
}
}
-2
View File
@@ -47,8 +47,6 @@ const chatRoute = Route.make({
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport,
headers: ({ request }): Record<string, string> =>
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
})
export const routes = [responsesRoute, chatRoute]
+7 -1
View File
@@ -255,7 +255,13 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
return Effect.succeed(event)
}),
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
Stream.onEnd(
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(incompleteStreamError(route)),
),
),
)
})
+2 -14
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { Cause, Context, Effect, Layer } from "effect"
import {
FetchHttpClient,
Headers,
@@ -198,20 +198,8 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
}
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
}),
),
)
const providerMessage = (status: number, body: { readonly body?: string }) => {
if (body.body && body.body.length <= 500) {
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
}
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
return `Provider request failed with HTTP ${status}`
}
+72 -24
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -84,6 +84,7 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({
type: Schema.tag("text-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -92,6 +93,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -99,6 +101,7 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -106,6 +109,7 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -114,6 +118,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -121,6 +126,7 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -129,6 +135,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -137,6 +144,7 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
text: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -146,6 +154,7 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -154,6 +163,7 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -162,6 +172,7 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -172,6 +183,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
output: Schema.optional(ToolOutput),
@@ -183,6 +195,7 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
message: Schema.String,
error: Schema.optional(Schema.Defect()),
@@ -334,12 +347,14 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
interface ContentAssembly {
readonly contentIndex: number
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
interface ToolInputAssembly {
readonly name: string
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
}
}
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
const textContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
const reasoningContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "reasoning",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
...state,
@@ -404,26 +435,32 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
)
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
const ensureText = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.textParts[id]) return state
return {
...appendContent(state, textContent("", providerMetadata)),
...appendContent(state, textContent("", itemId, providerMetadata)),
textParts: {
...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
},
}
}
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.providerMetadata)
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
const current = started.textParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
}
}
@@ -431,32 +468,39 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
}
}
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
const ensureReasoning = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.reasoningParts[id]) return state
return {
...appendContent(state, reasoningContent("", providerMetadata)),
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
reasoningParts: {
...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
},
}
}
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.providerMetadata)
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
const current = started.reasoningParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
}
}
@@ -464,9 +508,10 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
const current = state.reasoningParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
}
}
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state,
toolInputs: {
...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
[event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
},
})
@@ -495,6 +540,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
itemId: event.itemId ?? current.itemId,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@@ -504,6 +550,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -513,6 +560,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -528,13 +576,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
const next = appendEvent(state, event)
switch (event.type) {
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
return ensureText(next, event.id, event.itemId, event.providerMetadata)
case "text-delta":
return reduceTextDelta(next, event)
case "text-end":
return reduceTextEnd(next, event)
case "reasoning-start":
return ensureReasoning(next, event.id, event.providerMetadata)
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
case "reasoning-delta":
return reduceReasoningDelta(next, event)
case "reasoning-end":
+3
View File
@@ -21,6 +21,9 @@ export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ResponseItemID = Schema.String
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
+35 -32
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -25,6 +25,7 @@ export const SystemPart = Object.assign(systemPartSchema, {
export const TextPart = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -45,34 +46,35 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
(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"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(Tool.Content),
}),
]).annotate({ identifier: "LLM.ToolResult" })
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
export const ToolResultValue = Object.assign(toolResultValueSchema, {
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
export const ToolResultValue = Object.assign(
Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(Tool.Content),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
},
},
})
)
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput {
readonly structured: unknown
@@ -120,6 +122,7 @@ export const ToolCallPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-call"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -137,6 +140,7 @@ export const ToolResultPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-result"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -153,6 +157,7 @@ export const ToolResultPart = Object.assign(
): ToolResultPart => ({
type: "tool-result",
id: input.id,
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
name: input.name,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
@@ -167,6 +172,7 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -180,7 +186,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
id: Schema.optional(Schema.String),
id: Schema.optional(ResponseItemID),
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -271,8 +277,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
cache: Schema.optional(CachePolicy),
// 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)),
}) {}
@@ -290,7 +294,6 @@ export namespace LLMRequest {
providerOptions: request.providerOptions,
http: request.http,
cache: request.cache,
promptCacheKey: request.promptCacheKey,
metadata: request.metadata,
})
+2 -2
View File
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
id: call.id,
name: call.name,
result: settlement.result,
providerMetadata: call.providerMetadata,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}),
]
: [
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
name: call.name,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}),
],
}
+7 -2
View File
@@ -1,6 +1,10 @@
import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema"
import type {
ToolCallPart,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
/**
@@ -240,7 +244,8 @@ const project = (
): ToolOutputType =>
ToolOutput.make(
toStructuredOutput?.(output) ?? output,
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
)
export { ToolFailure }
+5
View File
@@ -7,6 +7,7 @@ import * as Anthropic from "../src/providers/anthropic"
import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
import * as Azure from "../src/providers/azure"
import * as Cloudflare from "../src/providers/cloudflare"
import * as GitHubCopilot from "../src/providers/github-copilot"
import * as Google from "../src/providers/google"
import * as GoogleVertex from "../src/providers/google-vertex"
import * as GoogleVertexChat from "../src/providers/google-vertex-chat"
@@ -269,3 +270,7 @@ OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1")
// @ts-expect-error GitHub Copilot model selectors only accept model ids.
GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})
+18
View File
@@ -10,6 +10,7 @@ import {
OpenRouter,
XAI,
} from "@opencode-ai/ai/providers"
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
import {
OpenAIChat,
OpenAICompatibleChat,
@@ -59,6 +60,23 @@ describe("public exports", () => {
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
).toBeFunction()
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "responses",
}).model("mai-code-1-flash-picker").route.id,
).toBe("openai-responses")
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "chat",
}).model("gpt-5").route.id,
).toBe("openai-chat")
})
test("protocol barrels expose supported low-level routes", () => {
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"],
"tags": [
"prefix:anthropic-messages-cache",
"provider:anthropic",
"protocol:anthropic-messages",
"cache",
"tool"
],
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
"recordedAt": "2026-07-24T16:22:29.494Z"
},
@@ -1,7 +1,12 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"],
"tags": [
"prefix:bedrock-converse-cache",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"cache"
],
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
"recordedAt": "2026-07-23T02:29:10.955Z"
},
@@ -1,7 +1,11 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"],
"tags": [
"prefix:google-images",
"provider:google",
"protocol:google-images"
],
"name": "google-images/generates-an-image",
"recordedAt": "2026-07-19T16:05:51.868Z"
},
@@ -1,7 +1,11 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"],
"tags": [
"prefix:openai-images",
"provider:openai",
"protocol:openai-images"
],
"name": "openai-images/generates-an-image",
"recordedAt": "2026-07-19T14:41:43.188Z"
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,12 @@
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
"tags": [
"prefix:openai-compatible-chat",
"provider:openrouter",
"protocol:openai-chat",
"reasoning"
],
"name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z"
},
@@ -1,7 +1,14 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"],
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z"
},
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"],
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z"
},
@@ -1,7 +1,14 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"],
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z"
},
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"],
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z"
},
@@ -1,7 +1,14 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"],
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z"
},
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"],
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z"
},
@@ -1,7 +1,14 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/openai-tool-result",
"recordedAt": "2026-07-22T18:15:36.438Z"
},
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
"tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/openai-user-input",
"recordedAt": "2026-07-22T18:15:34.867Z"
},
@@ -1,7 +1,14 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/xai-tool-result",
"recordedAt": "2026-07-22T18:15:43.608Z"
},
@@ -1,7 +1,13 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
"tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/xai-user-input",
"recordedAt": "2026-07-22T18:15:42.429Z"
},
@@ -2,7 +2,12 @@
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
"tags": [
"prefix:openai-compatible-chat",
"provider:vercel-ai-gateway",
"protocol:openai-chat",
"reasoning"
],
"name": "vercel-ai-gateway-reasoning",
"recordedAt": "2026-07-18T11:28:42.077Z"
},
@@ -1,7 +1,11 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"],
"tags": [
"prefix:xai-images",
"provider:xai",
"protocol:xai-images"
],
"name": "xai-images/generates-an-image",
"recordedAt": "2026-07-19T15:56:20.098Z"
},
+14 -5
View File
@@ -8,7 +8,6 @@ import {
type ProviderMetadata,
type ToolCallPart,
ToolResultPart,
type ToolResultValue,
type Usage,
} from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool"
@@ -61,9 +60,10 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...dispatched.map(([call, dispatched]) =>
Message.tool({
id: call.id,
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
name: call.name,
result: dispatched.result,
providerMetadata: call.providerMetadata,
providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
}),
),
],
@@ -89,9 +89,15 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
} else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
appendText(
assistantContent,
event.type === "text-end" ? "text" : "reasoning",
"",
event.itemId,
event.providerMetadata,
)
} else if (event.type === "tool-call") {
assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event)
@@ -99,6 +105,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
assistantContent.push(
ToolResultPart.make({
id: event.id,
itemId: event.itemId,
name: event.name,
result: event.result,
providerExecuted: true,
@@ -118,6 +125,7 @@ const appendText = (
content: ContentPart[],
type: "text" | "reasoning",
text: string,
itemId?: string,
providerMetadata?: ProviderMetadata,
) => {
const last = content.at(-1)
@@ -125,11 +133,12 @@ const appendText = (
content[content.length - 1] = {
...last,
text: `${last.text}${text}`,
itemId: itemId ?? last.itemId,
providerMetadata: providerMetadata ?? last.providerMetadata,
}
return
}
content.push({ type, text, providerMetadata })
content.push({ type, text, itemId, providerMetadata })
}
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" })
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Prompt cache keys must be strings.
promptCacheKey: 1,
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
providerOptions: { openai: { promptCacheKey: 1 } },
})
@@ -0,0 +1,13 @@
import { LLM } from "../../src"
import { GitHubCopilot } from "../../src/providers"
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
providerOptions: { openai: { reasoningSummary: "full" } },
})
@@ -519,8 +519,14 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }],
),
),
@@ -555,7 +561,10 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
+26 -91
View File
@@ -15,8 +15,6 @@ import {
} from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as XAI from "../../src/providers/xai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route"
@@ -102,24 +100,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("concatenates assistant text parts without adding separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
]),
],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -174,47 +154,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the request prompt cache key", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatible.configure({
baseURL: "https://api.compatible.test/v1",
apiKey: "test",
}).model("compatible-model"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -596,7 +535,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
}),
)
@@ -845,7 +784,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves scalar reasoning after content starts", () =>
it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -861,11 +800,11 @@ describe("OpenAI Chat route", () => {
),
)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
openai: { reasoningDetails: details },
})
}),
)
@@ -965,7 +904,7 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
@@ -1015,7 +954,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
@@ -1040,7 +979,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
@@ -1062,7 +1001,7 @@ describe("OpenAI Chat route", () => {
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
])
}),
)
@@ -1170,7 +1109,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
@@ -1182,31 +1121,27 @@ describe("OpenAI Chat route", () => {
const input = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(response.events).toEqual([
expect(events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}),
)
@@ -300,9 +300,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
],
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
},
},
})
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "example-model",
input: [
{ role: "system", content: "You are concise." },
@@ -53,6 +53,8 @@ describe("Open Responses-compatible route", () => {
],
stream: true,
})
expect(prepared.body.input[0]).not.toHaveProperty("id")
expect(prepared.body.input[1]).not.toHaveProperty("id")
}),
)
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
system: LARGE_CACHEABLE_SYSTEM,
prompt: "Say hi.",
generation: { maxTokens: 16, temperature: 0 },
promptCacheKey: "recorded-cache-test",
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
})
const recorded = recordedTests({
@@ -69,6 +69,9 @@ describe("OpenAI Responses route", () => {
stream: true,
max_output_tokens: 20,
temperature: 0,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -329,7 +332,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
@@ -410,7 +413,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body).toEqual({
expect(prepared.body).toMatchObject({
model: "gpt-4.1-mini",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -425,6 +428,65 @@ describe("OpenAI Responses route", () => {
tools: undefined,
top_p: undefined,
})
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
expect(call?.id).toBeUndefined()
expect(output?.id).toBeUndefined()
}),
)
it.effect("does not generate response item ids for client-created history", () =>
Effect.sync(() => {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Working." },
{ type: "reasoning", text: "Thinking." },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
],
})
expect(canonical.messages[0]?.id).toBeUndefined()
expect(canonical.messages[1]?.id).toBeUndefined()
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
true,
)
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
}),
)
it.effect("replays provider function call item ids without assigning output ids", () =>
Effect.gen(function* () {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Calling.", itemId: "plain-text" },
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: {},
providerMetadata: { openai: { itemId: "plain-call" } },
}),
]),
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
],
})
const prepared = yield* compileRequest(canonical)
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
"plain-text",
undefined,
])
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
expect(prepared.body.input).toEqual([
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
{ type: "function_call", id: "plain-call", call_id: "call_1", name: "lookup", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
])
}),
)
@@ -682,9 +744,9 @@ describe("OpenAI Responses route", () => {
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
providerOptions: {
openai: {
promptCacheKey: "session_123",
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
@@ -803,16 +865,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps the request prompt cache key", () =>
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
apiKey: "test",
providerOptions: { openai: { promptCacheKey: "model_cache" } },
}).model("gpt-4.1-mini"),
prompt: "no cache",
promptCacheKey: "request_cache",
providerOptions: { openai: { promptCacheKey: "request_cache" } },
}),
)
@@ -863,9 +926,21 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "Hello",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "!",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1" },
{
type: "step-finish",
@@ -922,17 +997,20 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
},
])
@@ -940,16 +1018,19 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
role: "assistant",
id: "msg_commentary",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
id: "msg_final",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
id: "msg_null",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -1042,12 +1123,24 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "First",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{
type: "text-delta",
id: "msg_2",
itemId: "msg_2",
text: "Second",
providerMetadata: { openai: { itemId: "msg_2" } },
},
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
)
@@ -1067,9 +1160,15 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-delta",
id: "rs_1",
itemId: "rs_1",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
@@ -1078,8 +1177,8 @@ describe("OpenAI Responses route", () => {
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
@@ -1110,6 +1209,7 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -1150,19 +1250,34 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning-start",
id: "rs_1:0",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-delta",
id: "rs_1:0",
itemId: "rs_1",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-delta",
id: "rs_1:1",
itemId: "rs_1",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-end",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1200,8 +1315,8 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
@@ -1249,7 +1364,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
expect(body.input[1]).toHaveProperty("id", "rs_1")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1296,6 +1411,7 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
@@ -1304,7 +1420,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("references stored reasoning items by id", () =>
it.effect("replays complete stored reasoning items with their id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1322,7 +1438,14 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
}),
)
@@ -1431,6 +1554,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -1510,6 +1634,7 @@ describe("OpenAI Responses route", () => {
outputTokens: 1,
nonCachedInputTokens: 5,
cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined,
totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1520,30 +1645,35 @@ describe("OpenAI Responses route", () => {
{
type: "tool-input-start",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: '{"query"',
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: ':"weather"}',
},
{
type: "tool-input-end",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1563,6 +1693,17 @@ describe("OpenAI Responses route", () => {
usage,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
])
}),
)
@@ -1595,6 +1736,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
id: "call_1",
itemId: "item_1",
name: "lookup",
raw: '{"query":"partial',
})
@@ -1651,6 +1793,7 @@ describe("OpenAI Responses route", () => {
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
@@ -1659,11 +1802,35 @@ describe("OpenAI Responses route", () => {
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
output: undefined,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
metadata: undefined,
cache: undefined,
},
])
}),
)
@@ -1741,6 +1908,7 @@ describe("OpenAI Responses route", () => {
expect(toolCall).toEqual({
type: "tool-call",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
input: { code: "print(1+1)", container_id: "cnt_xyz" },
providerExecuted: true,
@@ -1750,10 +1918,12 @@ describe("OpenAI Responses route", () => {
expect(toolResult).toEqual({
type: "tool-result",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1" } },
providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
})
}),
)
+1 -1
View File
@@ -162,6 +162,7 @@ describe("OpenRouter", () => {
openrouter: {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
@@ -173,7 +174,6 @@ describe("OpenRouter", () => {
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
prompt: "Think briefly.",
promptCacheKey: "session_123",
}),
)
+37
View File
@@ -49,6 +49,43 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
})
test("assembles response item identity and provider metadata", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.textDelta({
id: "text-block",
itemId: "msg_existing",
text: "Answer",
providerMetadata: { openai: { itemId: "msg_existing" } },
}),
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.reasoningDelta({
id: "reasoning-block",
itemId: "rs_existing",
text: "Thought",
providerMetadata: { openai: { itemId: "rs_existing" } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{
type: "text",
text: "Answer",
itemId: "msg_existing",
providerMetadata: { openai: { itemId: "msg_existing" } },
},
{
type: "reasoning",
text: "Thought",
itemId: "rs_existing",
providerMetadata: { openai: { itemId: "rs_existing" } },
},
])
})
test("does not complete ended content without a terminal finish", () => {
const state = reduce([
LLMEvent.textStart({ id: "t1" }),
+29 -5
View File
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([
expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({
id: "call_projected",
name: "projected",
@@ -180,6 +180,7 @@ describe("LLMClient tools", () => {
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
@@ -197,7 +198,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toEqual([
expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
@@ -206,12 +207,13 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toEqual([
expect(failed.events).toMatchObject([
LLMEvent.toolError({
id: "call_2",
name: "missing",
@@ -225,6 +227,27 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
expect(errorItemID).toBeUndefined()
expect(resultItemID).toBeUndefined()
}),
)
it.effect("does not derive a function output item id from the function call item id", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
)
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
}),
)
@@ -437,7 +460,7 @@ describe("LLMClient tools", () => {
)
expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toEqual([
expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({
id: "call_1",
name: "eventful",
@@ -445,6 +468,7 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
-15
View File
@@ -19,21 +19,6 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
-16
View File
@@ -1,16 +0,0 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
@@ -8,7 +8,7 @@ import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { installSseTransport } from "../../utils/sse-transport"
import { expectSessionReady } from "../../utils/waits"
import { expectSessionTitle } from "../../utils/waits"
export const directory = "C:/OpenCode/TimelineStability"
export const projectID = "proj_timeline_stability"
@@ -111,9 +111,8 @@ export async function setupTimeline(
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
decodeOptions,
)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const transport = await installSseTransport<EventPayload>(page, {
server,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
@@ -162,8 +161,8 @@ export async function setupTimeline(
})
}
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
await transport.waitForConnection()
await expectSessionTitle(page, title)
if (input.cpuRate && input.cpuRate > 1) {
const devtools = await page.context().newCDPSession(page)
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
@@ -199,9 +198,7 @@ export async function setupTimeline(
)
},
async waitForPart(partID: string) {
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
},
}
}
@@ -177,8 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
const firstUser = userMessage(undefined, {
summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`,
status: "modified",
file: `src/diff-${index}.ts`,
status: "modified",
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -85,7 +85,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -1,60 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -181,17 +181,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
data: [
{
id: remote ? "server-b" : "server-a",
name: remote ? "Server B Provider" : "Server A Provider",
package: "test",
},
],
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
})
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
@@ -58,7 +58,8 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
if (url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
@@ -18,7 +18,6 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
@@ -28,7 +27,6 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
@@ -38,10 +36,15 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start)
await expectAppVisible(end)
await start.dragTo(end)
const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
@@ -51,38 +54,31 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
})
const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
const textbox = review.getByRole("textbox")
await expect(textbox).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await review.getByRole("textbox").fill("Use the existing value instead")
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
})
async function openReview(page: Page) {
@@ -148,22 +144,15 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const changes = page.getByRole("tab", { name: "Changes" })
const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
const file = review.locator('[data-file="src/review.ts"]')
await expectAppVisible(file)
const trigger = file.getByRole("button", { expanded: false })
await expect(trigger).toHaveCount(1)
await trigger.click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
await review
.getByRole("heading", { name: /review\.ts/ })
.getByRole("button")
.first()
.click()
}
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionReady } from "../utils/waits"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
@@ -19,6 +19,10 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
@@ -53,8 +57,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
sessionStatus: () => sessionStatus,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
@@ -67,12 +73,20 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
)
await page.route("**/api/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: branchDiffs,
data: detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs,
}),
})
})
@@ -132,8 +146,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await expectSessionReady(page, { server, sessionID, title })
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
@@ -141,6 +154,77 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page)
})
async function expectTree(page: Page, total: number, file: string) {
@@ -171,32 +255,36 @@ async function expectStackGeometry(page: Page) {
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
const sidebar = review.querySelector<HTMLElement>('[data-slot="session-review-v2-sidebar"]')!
return {
review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
sidebar: sidebar.getBoundingClientRect().width,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function fileDiff(file: string, additions: number, loaded = true) {
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "de", label: "Erkundet" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
@@ -86,10 +86,15 @@ test.describe("session timeline projection", () => {
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
})
const aborted = assistantMessage(
[
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
],
{
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
)
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed",
error: {
@@ -1,5 +1,12 @@
import { expect, test, type Page } from "@playwright/test"
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
status,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("keeps one connection open while delivering multiple events", async ({ page }) => {
const timeline = await setupTimeline(page)
@@ -10,7 +17,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.connections()).toHaveLength(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
})
@@ -44,24 +51,20 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
})
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const timeline = await setupTimeline(page)
const partID = "prt_transport_heartbeat_sentinel"
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
await timeline.waitForPart(partID)
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
"data-markdown-ready",
"",
)
const before = await timelineRows(page)
const heartbeat = await timeline.transport.heartbeat()
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
})
const before = await page.locator("[data-timeline-row]").allTextContents()
await expect.poll(() => timelineRows(page)).toEqual(before)
expect(heartbeat.connectionID).toBe(sentinel.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
await timeline.transport.heartbeat()
await timeline.settle()
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
expect(await timeline.transport.connections()).toHaveLength(1)
})
test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page)
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection()
await timeline.transport.close()
@@ -74,14 +77,13 @@ test("reconnects after a clean close", async ({ page }) => {
})
test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page)
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.transport.send(status("busy"))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
@@ -110,18 +112,5 @@ test("passes through non-event fetches", async ({ page }) => {
})
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.connections()).toHaveLength(1)
})
function timelineRows(page: Page) {
return page.locator("[data-timeline-row]").evaluateAll((rows) =>
rows.map((row) => ({
kind: row.getAttribute("data-timeline-row"),
message: row.getAttribute("data-message-id"),
parts: Array.from(row.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: row.textContent,
})),
)
}
@@ -89,7 +89,8 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page)
await switchTab(page, titleB)
@@ -300,8 +300,9 @@ export const fixture = {
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
.callID,
expandedShellPartID: targetMessages
.flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
},
}
+23 -25
View File
@@ -144,8 +144,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model")
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
@@ -207,8 +206,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
@@ -283,8 +281,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
return json(route, true)
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
return json(route, true)
if (
@@ -389,13 +386,11 @@ function providerConfig(config: MockServerConfig) {
function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all
.filter(record)
.flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [{ id: provider.id, name: provider.name, package: provider.id }]
: [],
)
return value.all.filter(record).flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [{ id: provider.id, name: provider.name, package: provider.id }]
: [],
)
}
function currentModels(value: unknown) {
@@ -445,7 +440,9 @@ function currentDefaultModel(value: unknown) {
if (!record(value) || !record(value.default)) return null
const selected = value.default
const models = currentModels(value)
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null
return models.find(
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
) ?? null
}
function currentPermission(value: unknown) {
@@ -565,16 +562,22 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
}
function mentionFrom(value: Record<string, unknown> | undefined) {
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
if (
!value ||
typeof value.value !== "string" ||
typeof value.start !== "number" ||
typeof value.end !== "number"
)
return
return { text: value.value, start: value.start, end: value.end }
}
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
function legacyAssistantContent(
part: Record<string, unknown>,
created: number,
): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string")
return [
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
]
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined
return [
@@ -615,12 +618,7 @@ function legacyAssistantContent(part: Record<string, unknown>, created: number):
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "pending")
return [
{
...base,
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
},
]
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
if (state.status === "completed")
return [
{
+7 -11
View File
@@ -174,7 +174,8 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
if (url.origin !== server || url.pathname !== "/api/event")
return originalFetch(request)
const id = ++nextConnectionID
const record = {
@@ -234,23 +235,18 @@ export async function installSseTransport<T>(
return {
server,
async waitForConnection(input = {}) {
const connection = await page.waitForFunction(
await page.waitForFunction(
(after) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
return connections?.some((connection) => connection.id > after)
},
input.after ?? 0,
{ timeout: input.timeout },
)
let result: SseConnectionRecord | undefined
try {
result = await connection.jsonValue()
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
(connection) => connection.id > (input.after ?? 0),
)!
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
-6
View File
@@ -1,5 +1,4 @@
import { expect, type Locator, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const APP_READY_TIMEOUT = 30_000
@@ -10,8 +9,3 @@ export async function expectAppVisible(locator: Locator) {
export async function expectSessionTitle(page: Page, title: string) {
await expectAppVisible(page.getByRole("heading", { name: title }))
}
export async function expectSessionReady(page: Page, input: { server: string; sessionID: string; title: string }) {
await expect(page).toHaveURL(`/server/${base64Encode(input.server)}/session/${input.sessionID}`)
await expectSessionTitle(page, input.title)
}
+1 -4
View File
@@ -20,10 +20,7 @@
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body
data-new-layout
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
>
<body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script>
+3 -4
View File
@@ -1,12 +1,11 @@
{
"name": "@opencode-ai/app",
"version": "1.18.15",
"version": "1.18.8",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
@@ -20,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
+16 -17
View File
@@ -3,12 +3,20 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import {
type BaseRouterProps,
Navigate,
Route,
Router,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -154,13 +162,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>
{props.children}
</I18nProvider>
)
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
}
declare global {
@@ -223,7 +225,7 @@ function DesktopCommands() {
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: language.t("command.logs.export"),
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
@@ -272,12 +274,7 @@ function DraftProviders(props: ParentProps) {
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
}>,
) {
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
<Font />
@@ -286,7 +283,7 @@ export function AppBaseProviders(
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
@@ -297,7 +294,9 @@ export function AppBaseProviders(
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
+27 -64
View File
@@ -60,44 +60,35 @@ function Cell(props: {
label: string
tip: string
value: string
span?: 2 | 3
wide?: boolean
}) {
const content = () => (
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": props.span === 2 && !props.inline,
"col-span-3": props.span === 3 && !props.inline,
"col-span-2": !!props.wide && !props.inline,
}}
>
<div
classList={{
"flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
@@ -117,50 +108,37 @@ function Cell(props: {
)
}
function ToggleCell(props: {
active: boolean
inline?: boolean
label: string
onClick: () => void
tip: string
value: string
}) {
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
const content = () => (
<button
type="button"
aria-label={`${props.label}: ${props.value}`}
aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span
classList={{
flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value={props.tip} placement="top">
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value={props.tip} placement="top">
<Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()}
</Tooltip>
)
@@ -479,7 +457,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
"gap-[9px]": !!props.inline,
"gap-px": !props.inline,
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
"grid-cols-4": !props.inline,
"grid-cols-5": !props.inline,
grid: !props.inline,
}}
>
@@ -561,25 +539,10 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
wide={!platform.setForceFocus}
/>
{platform.setForceFocus && (
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
)}
</div>
</aside>
@@ -1,4 +1,4 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -29,7 +29,7 @@ import {
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -40,8 +40,6 @@ import { decode64 } from "@/utils/base64"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -436,16 +434,16 @@ function ProviderConnection(props: {
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as undefined | "pending" | "complete" | "error" | "form",
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
@@ -456,7 +454,7 @@ function ProviderConnection(props: {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.formAnswer = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
@@ -464,18 +462,18 @@ function ProviderConnection(props: {
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.formAnswer = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.form") {
draft.state = "form"
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.answer") {
draft.formAnswer = action.answer
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
@@ -511,12 +509,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint:
hint?.toLowerCase() === "headless"
? language.t("provider.connect.method.headless")
: hint?.toLowerCase() === "browser" || (!hint && value?.type === "key")
? language.t("provider.connect.method.browser")
: undefined,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
}
}
@@ -538,7 +531,7 @@ function ProviderConnection(props: {
return fallback
}
async function selectMethod(index: number, answer?: FormAnswer) {
async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
@@ -547,17 +540,9 @@ function ProviderConnection(props: {
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
if (method.type === "key") {
dispatch({ type: "auth.answer", answer })
return
}
if (method.type === "oauth") {
if (method.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
@@ -565,7 +550,7 @@ function ProviderConnection(props: {
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
...(answer ? { answer } : {}),
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
@@ -579,42 +564,41 @@ function ProviderConnection(props: {
}
}
function AuthFormView() {
function AuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const fields = createMemo<StringForm[]>(() => {
const prompts = createMemo(() => {
const value = method()
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
return value?.type === "oauth" ? (value.prompts ?? []) : []
})
const matches = (field: StringForm, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => {
const actual = value[condition.key]
if (actual === undefined) return false
return condition.op === "eq" ? actual === condition.value : actual !== condition.value
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = fields()
const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
field: all[index],
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.field.options) return false
if (!item.field.required) return true
return (formStore.value[item.field.key] ?? "").trim().length > 0
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = fields().findIndex((field, i) => i > index && matches(field, value))
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
@@ -625,60 +609,60 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.field.options) return
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const field = item()?.field
if (!field || field.options) return
return field
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const field = item()?.field
if (!field?.options) return
return field
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.field.options === undefined}>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.title ?? ""}
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const field = text()
if (!field) return
setFormStore("value", field.key, value)
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.field.options !== undefined}>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div>
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const field = select()
if (!field) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[field.key]: value.value,
[prompt.key]: value.value,
}
setFormStore("value", field.key, value.value)
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
@@ -688,7 +672,7 @@ function ProviderConnection(props: {
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.description}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
@@ -836,7 +820,6 @@ function ProviderConnection(props: {
integrationID: props.provider,
location: location(),
key: apiKey,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
})
await complete()
}
@@ -853,12 +836,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
<Link
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -903,9 +886,9 @@ function ProviderConnection(props: {
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -984,9 +967,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
<Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1023,9 +1006,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1096,9 +1077,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@@ -1164,8 +1143,8 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={store.state === "form"}>
<AuthFormView />
<Match when={store.state === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
@@ -8,7 +8,8 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -42,6 +43,7 @@ export function DialogCustomProvider(props: Props) {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -130,7 +132,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
throw new Error(language.t("provider.custom.unavailable"))
throw new Error(`Custom provider ${result.providerID} is unavailable`)
},
onSuccess: (result) => {
dialog.close()
@@ -166,9 +168,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</ExternalLink>
</Link>
{language.t("provider.custom.description.suffix")}
</p>

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