Compare commits

..

68 Commits

Author SHA1 Message Date
Jarred Sumner d6625397d9 tui: remove the win32 ENABLE_PROCESSED_INPUT ffi shim (#43604) 2026-08-20 09:04:40 +00:00
opencode-agent[bot] ab77fb080a chore: update nix node_modules hashes 2026-08-20 08:06:55 +00:00
Luke Parker 2a7d0729d0 feat(desktop): optimize cold development startup (#42722) 2026-08-20 17:40:50 +10:00
Aiden Cline e2d9376614 fix(ai): preserve response item ids (#43590) 2026-08-20 01:50:04 -05:00
Major Hayden f288d7e107 fix(ai): isolate Gemini function-response turns (#43479)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-20 01:31:36 -05:00
Dmitry Nefedov afe4c5d23a fix(tui): utilize TUI cursor style config in opencode2 mini (#43529) 2026-08-20 01:24:33 -05:00
Aiden Cline 98ad4465f8 fix(core): preserve discovered model limits (#43589) 2026-08-20 01:11:34 -05:00
Aiden Cline 5b1e8450e7 feat(ai): preserve streamed refusals as text (#43343) 2026-08-20 00:47:18 -05:00
Aiden Cline 99490f289b refactor(ai): remove model limits (#43581) 2026-08-20 00:36:35 -05:00
opencode-agent[bot] e37c7be434 fix(app): resolve branch from active directory (#43579)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-20 05:01:01 +00:00
Aiden Cline 39f4adb4dc fix(ai): settle pending Responses tool calls (#43575) 2026-08-19 23:53:27 -05:00
Dax Raad 393b43a881 Revert "feat(stats): combine free and go usage (#43577)"
This reverts commit 394b5ac5fd.
2026-08-20 00:49:46 -04:00
opencode-agent[bot] 394b5ac5fd feat(stats): combine free and go usage (#43577)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-20 00:43:44 -04:00
opencode-agent[bot] dbc7d0ee09 chore: update nix node_modules hashes 2026-08-20 04:01:43 +00:00
Aiden Cline 08f26a2d2e feat(ai): support Responses request options (#43360) 2026-08-19 22:45:26 -05:00
Kit Langton a51622a0e7 chore: upgrade Effect to rc.110 (#43567) 2026-08-20 03:43:23 +00:00
Kit Langton 20ff543ff2 fix(tui): honest reconnect overlay copy without a managed service (#43561) 2026-08-20 03:33:57 +00:00
Kit Langton f43474043a feat(core): acknowledge session interruption immediately (#43552) 2026-08-20 03:16:32 +00:00
Aiden Cline 5a0ba34d64 fix(core): expire stale shell output (#43554) 2026-08-19 21:58:58 -05:00
opencode-agent[bot] 9a1de86d9c refactor(core): own resolved model limits (#43545)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 21:40:33 -05:00
Kit Langton ea7fa43243 refactor(core): share session model requests (#42680) 2026-08-20 02:33:46 +00:00
opencode-agent[bot] 730e1935cf chore: update nix node_modules hashes 2026-08-20 02:33:37 +00:00
Luke Parker d6deed6752 refactor(session-ui): render current messages directly across surfaces (#43345) 2026-08-20 12:16:03 +10:00
Kit Langton 1d89e911e8 refactor(core): make prompt ID reuse idempotent (#43548) 2026-08-20 01:56:40 +00:00
opencode-agent[bot] c85b09de6f fix(core): default unknown model token limits (#43541)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 20:04:01 -05:00
Kit Langton 30db9dd86e chore(app): use schema ID minting instead of hand-rolled encoder (#43542) 2026-08-19 20:59:35 -04:00
Kit Langton b6966177fa refactor(core): simplify interrupt continuation (#42810) 2026-08-19 20:40:32 -04:00
Kit Langton 6b09b9e6a2 feat(client): optimistic prompt admission with client-minted IDs (#43520) 2026-08-19 20:34:12 -04:00
xdagiz 3876f7aad6 fix(desktop): show window on did-finish-load fallback for wayland (#42681) 2026-08-20 00:02:46 +00:00
Filip d912202cf2 feat: better skill ux (#43523) 2026-08-20 01:27:57 +02:00
Kit Langton f8c46684eb fix: eliminate flaky CI races (#43522) 2026-08-19 22:12:41 +00:00
Kit Langton c4afbc4aae fix(tui): handle form clipboard shortcut (#43526) 2026-08-19 18:10:44 -04:00
Dax 98a9d864e6 feat(plugin): add durable storage API (#43525) 2026-08-19 21:51:58 +00:00
Kit Langton 6adb98c266 refactor(ai): flatten provider request options (#43513) 2026-08-19 17:32:29 -04:00
Kit Langton 83ffd292f8 docs(ai): remove stale design documents (#43512) 2026-08-19 17:32:23 -04:00
opencode-agent[bot] 8fb534d03e feat(tui): close tabs with middle click (#43497)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-19 16:40:21 -04:00
opencode-agent[bot] d4a0f163a9 test(core): isolate model resolver auth config (#43509)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 15:25:23 -05:00
Aiden Cline a9caf9154e fix: move around ai pkg logic (#43504) 2026-08-19 15:24:03 -05:00
opencode-agent[bot] e0be1c0e13 fix(ai): match AI SDK Azure URL handling (#43506)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 15:04:54 -05:00
Aiden Cline 8a03b87c1f fix(ai): avoid azure chat v1 url duplication (#43505) 2026-08-19 14:50:00 -05:00
opencode-agent[bot] 173e3b0d48 chore: update nix node_modules hashes 2026-08-19 18:59:43 +00:00
ReStranger 1df9092f07 feat(ci): enable nix-hashes ci in v2 branch (#43482)
Signed-off-by: ReStranger <restranger@disroot.org>
2026-08-19 20:37:59 +02:00
Aiden Cline fbf45f2384 fix(core): centralize native provider mapping (#43468) 2026-08-19 12:32:53 -05:00
ReStranger 9292bfbe49 feat(nix): update cli packaging (#43469)
Signed-off-by: ReStranger <restranger@disroot.org>
2026-08-19 11:19:13 -05:00
Shoubhit Dash 5d453752f0 refactor(core): move location watcher config into state (#43464) 2026-08-19 21:19:32 +05:30
Kit Langton 16b77e5b50 fix(tui): soften jump to latest action (#43457) 2026-08-19 11:41:57 -04:00
Dax c1583ddcbb docs: move CPU profiling to dev skill (#43448) 2026-08-19 11:29:06 -04:00
Kit Langton d8debaa449 fix(tui): stabilize plugin reload generations (#43447) 2026-08-19 15:26:54 +00:00
Shoubhit Dash ace822308f refactor(core): move compaction config into state (#43442) 2026-08-19 20:43:01 +05:30
opencode-agent[bot] 728053b645 fix(tui): avoid retrying broken plugin setup (#43441)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 20:41:13 +05:30
opencode-agent[bot] bfe9917ee7 fix(core): lower AI SDK system updates (#43440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 10:03:03 -05:00
opencode-agent[bot] 1556b74082 fix(core): preserve Vertex billing labels (#43449)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 09:57:47 -05:00
Dax 7700faad81 feat(cli): capture CPU profiles with SIGPROF (#43446) 2026-08-19 10:45:05 -04:00
Kit Langton 241a88a5d9 fix(tui): open subagent panel from footer (#43325) 2026-08-19 10:33:44 -04:00
Major Hayden 3edcb3ca2b fix(core): route Vertex Gemini through native provider (#43433)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-19 08:59:11 -05:00
opencode-agent[bot] dcc7de2e47 test(app): stabilize reconnect offset timing (#43438)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-19 19:27:34 +05:30
Shoubhit Dash c40c306170 refactor(core): move shell config into state (#43430) 2026-08-19 19:23:22 +05:30
Shoubhit Dash a207253242 fix(core): await snapshot state readiness (#43435) 2026-08-19 19:23:10 +05:30
Shoubhit Dash 1e867c228a refactor(core): move snapshot config into state (#43425) 2026-08-19 18:34:54 +05:30
Shoubhit Dash 8a402d3f03 refactor(core): move tool output config into state (#43422) 2026-08-19 18:25:22 +05:30
opencode-agent[bot] 33567c5792 fix(desktop): connect wildcard service through loopback (#43171)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-19 14:39:54 +10:00
Aiden Cline daf3f9ed08 feat(ai): support Responses tool controls (#43329) 2026-08-18 23:33:04 -05:00
opencode-agent[bot] d5bf8799c0 fix(cli): keep run event stream alive (#43348)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-18 23:31:26 -05:00
Dax f6f64d7ece feat(cli): manage plugin packages (#43283) 2026-08-19 00:06:15 -04:00
Aiden Cline 6baad7fc3e feat(ai): support Responses truncation policy (#43339) 2026-08-18 22:54:37 -05:00
Dax 0762d63b6a fix(core): identify user-initiated web fetches (#43330) 2026-08-18 23:51:52 -04:00
Aiden Cline 4df0591025 feat(core): retain provider finish details (#43332) 2026-08-18 22:40:13 -05:00
Aiden Cline 30cb420900 feat(ai): lower system updates as developer (#43326) 2026-08-18 22:20:42 -05:00
527 changed files with 16891 additions and 18629 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev, beta] branches: [dev, beta, v2]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"
+27
View File
@@ -185,6 +185,33 @@ pmap -x <pid> | sort -k3 -nr | head -25
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone. Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## Debugger ## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: - To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior. - Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
+1427 -813
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=", "x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=", "aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=", "aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU=" "x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
} }
} }
+2 -2
View File
@@ -10,7 +10,7 @@
]).nodeModules.${stdenvNoCC.hostPlatform.system}, ]).nodeModules.${stdenvNoCC.hostPlatform.system},
}: }:
let let
packageJson = lib.pipe ../packages/opencode/package.json [ packageJson = lib.pipe ../packages/cli/package.json [
builtins.readFile builtins.readFile
builtins.fromJSON builtins.fromJSON
]; ];
@@ -52,7 +52,7 @@ stdenvNoCC.mkDerivation {
--cpu="${bunCpu}" \ --cpu="${bunCpu}" \
--os="${bunOs}" \ --os="${bunOs}" \
--filter '!./' \ --filter '!./' \
--filter './packages/opencode' \ --filter './packages/cli' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--frozen-lockfile \ --frozen-lockfile \
+8 -10
View File
@@ -48,13 +48,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "prod"; env.OPENCODE_CHANNEL = "prod";
env.NODE_OPTIONS = "--max-old-space-size=4096";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
cd ./packages/opencode cd ./packages/cli
bun --bun ./script/build.ts --single --skip-install bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
runHook postBuild runHook postBuild
''; '';
@@ -62,10 +62,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode \ wrapProgram $out/bin/opencode2 \
--prefix PATH : ${ --prefix PATH : ${
lib.makeBinPath ( lib.makeBinPath (
[ [
@@ -81,9 +80,9 @@ stdenvNoCC.mkDerivation (finalAttrs: {
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions # trick yargs into also generating zsh completions
installShellCompletion --cmd opencode \ installShellCompletion --cmd opencode2 \
--bash <($out/bin/opencode completion) \ --bash <($out/bin/opencode2 completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion) --zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
''; '';
nativeInstallCheckInputs = [ nativeInstallCheckInputs = [
@@ -95,7 +94,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
versionCheckProgramArg = "--version"; versionCheckProgramArg = "--version";
passthru = { passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
env = finalAttrs.env; env = finalAttrs.env;
}; };
@@ -103,7 +101,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
description = "The open source coding agent"; description = "The open source coding agent";
homepage = "https://opencode.ai"; homepage = "https://opencode.ai";
license = lib.licenses.mit; license = lib.licenses.mit;
mainProgram = "opencode"; mainProgram = "opencode2";
inherit (node_modules.meta) platforms; inherit (node_modules.meta) platforms;
}; };
}) })
+6 -5
View File
@@ -15,6 +15,7 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev", "dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint", "lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
@@ -37,10 +38,10 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.107", "@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-beta.107", "@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-beta.107", "@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-beta.107", "@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
@@ -71,7 +72,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.107", "effect": "4.0.0-rc.110",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
+8 -58
View File
@@ -49,7 +49,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
### Routes ### Routes
A route is the registered, runnable composition of four orthogonal pieces: A route is the runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. - **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). - **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
@@ -66,7 +66,7 @@ export const route = Route.make({
endpoint: Endpoint.path("/chat/completions", { endpoint: Endpoint.path("/chat/completions", {
baseURL: "https://api.openai.com/v1", baseURL: "https://api.openai.com/v1",
}), }),
auth: Auth.bearer(), auth: Auth.bearer(Auth.config("OPENAI_API_KEY")),
framing: Framing.sse, framing: Framing.sse,
}) })
``` ```
@@ -79,7 +79,7 @@ When a provider supports multiple physical transports, selection remains executi
### URL Construction ### URL Construction
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) require configuration before execution. `Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Generic OpenAI-compatible routes have no canonical URL and require configuration before execution.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`). For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
@@ -126,54 +126,6 @@ Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
### Folder layout
```
packages/ai/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, AIError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
auth.ts Auth type + Auth.bearer / Auth.apiKeyHeader / Auth.passthrough
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing
websocket.ts direct one-request channel executor + raw socket adapter
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic Chat helper + family model helpers
openai-compatible-responses.ts generic Responses helper
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed tool() helper
tool-runtime.ts narrow one-call typed tool dispatcher
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension. The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
### Shared protocol helpers ### Shared protocol helpers
@@ -193,7 +145,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only. `LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation: Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
```text ```text
<system-update> <system-update>
@@ -221,19 +173,17 @@ Routes lower these into provider-native assistant tool-call messages and tool-re
### Tool dispatch ### Tool dispatch
`LLM.stream(request)` and `LLM.generate(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`. `LLM.stream(request)` and `LLM.generate(request)` each run exactly one model call. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
```ts ```ts
const get_weather = tool({ const get_weather = Tool.make({
description: "Get current weather for a city", description: "Get current weather for a city",
parameters: Schema.Struct({ city: Schema.String }), parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: ({ city }) => execute: (input) =>
Effect.gen(function* () { Effect.gen(function* () {
// city: string — typed from parameters Schema const data = yield* WeatherApi.fetch(input.city)
const data = yield* WeatherApi.fetch(city)
return { temperature: data.temp, condition: data.cond } return { temperature: data.temp, condition: data.cond }
// return type checked against success Schema
}), }),
}) })
File diff suppressed because it is too large Load Diff
+17 -6
View File
@@ -237,11 +237,11 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
### Auto placement ### Auto placement
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback. `"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable. Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless. Requests below a provider's minimum cacheable size simply do not produce a reusable cache entry.
### Opting out ### Opting out
@@ -285,6 +285,7 @@ LLM.request({
| ----------------------- | ------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) | | Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) | | Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenRouter | emits up to 4 `cache_control` markers |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | | OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | | Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
@@ -308,7 +309,7 @@ Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and
### Package-like entrypoints ### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
```ts ```ts
import { model } from "@opencode-ai/ai/providers/openai/responses" import { model } from "@opencode-ai/ai/providers/openai/responses"
@@ -316,7 +317,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", { const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
}) })
``` ```
@@ -371,11 +371,23 @@ Request options in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop). 1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it. 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). 3. **`providerOptions: { ... }`** — flat options inferred from the selected model (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. 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.
Route/provider defaults are overridden by request-level values for each axis. Route/provider defaults are overridden by request-level values for each axis.
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
LLM.request({
model,
prompt,
providerOptions: {
reasoningEffort: "low",
},
})
```
## Routes ## Routes
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail. Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
@@ -387,6 +399,5 @@ This package is built on Effect. Public methods return `Effect` or `Stream`; pro
## See also ## See also
- `AGENTS.md` — architecture, route construction, contributor guide - `AGENTS.md` — architecture, route construction, contributor guide
- `STATUS.md` — native provider parity status and AI SDK migration gaps
- `example/tutorial.ts` — runnable end-to-end walkthrough - `example/tutorial.ts` — runnable end-to-end walkthrough
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes - `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
-107
View File
@@ -1,107 +0,0 @@
# LLM Provider Parity Status
Last reviewed: 2026-08-07
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
## Existing Status Sources
| File | What it tracks | Limitation |
| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
## Current Implementation Snapshot
| Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable over HTTP by default, with optional per-call WebSocket channel execution on the same model and route identity. | No incremental `previous_response_id` path or persistent Session channel manager yet. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
## V2 Runner Status
`packages/core/src/session/runner/model.ts` currently resolves only this native subset from catalog `aisdk` metadata:
| Catalog API | Native route used today |
| --------------------------------------------------- | ---------------------------- |
| `aisdk:@ai-sdk/openai` | `OpenAIResponses.route` |
| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` |
| `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` |
Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently fall back through the AI SDK loader in the production runner. The dependency-free resolver seam rejects them with `SessionRunnerModel.UnsupportedPackageError`; they are not native route mappings yet.
## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
## Native Namespace Shape
These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
-606
View File
@@ -1,606 +0,0 @@
# LLM Call Site Sketches
Scratchpad for examples first, abstractions second. Current direction: routes
execute, provider facades organize configured route sets, and models carry route
values directly.
## Conversation Summary
Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI
SDK transform path and into `packages/ai` where possible. The goal is not a big
generic transform layer; the goal is small composable route definitions backed by
recorded golden tests.
Things to keep testing against:
- Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage.
- Images: golden image tests for providers/protocols that claim image support.
- Reasoning: canonical reasoning parts/events versus provider-native knobs.
- Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth.
- OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers.
- Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts.
- Error quality: typed errors instead of generic SDK/server failures.
## Final Guide: Routes Execute, Providers Organize
Do not introduce a first-class `Deployment` abstraction unless it gains real
semantics. Provider facades are ergonomic configured route groups, not execution
registries. The executable/composable thing is still a route. Do not make route
construction publish to a global registry; models should carry their route value
directly.
Keep durable identity separate from runtime capability:
- Durable identity is small serializable data like `{ providerID, modelID }` for
config, sessions, logs, and catalogs.
- Runtime capability is a `LanguageModel` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route
side table.
Keep unconfigured behavior values as values, not factories. A transport like
`HttpTransport.sseJson` should be a reusable immutable value. Use a function only
when the caller supplies options or when construction needs fresh state.
Use constants to remove repetition before inventing abstractions. Provider ids
are branded once per provider facade and reused across routes; a plain exported
object is enough for the provider-facing API unless a helper earns its keep by
removing repeated route projection.
Expose default configured provider instances, and put provider-specific setup on
`.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`,
`chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before
model selection, not as a second argument to model selection.
Use provider/product facades consistently:
- One coherent provider/product config surface gets one top-level facade.
- APIs/model kinds that share that config are methods on the facade.
- Different products with different required config get separate top-level
facades, not a shared namespace with unrelated children.
- Default facades are exposed only when concrete defaults or lazy env/credential
defaults make the facade valid.
Examples:
```ts
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o")
CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct")
OpenAICompatible.configure({
provider: "custom",
baseURL: "https://custom.example/v1",
auth: Auth.bearer(apiKey),
}).model("custom-model")
```
Standardize the provider facade contract before abstracting construction. A
plain object is enough at first; add a helper only if repeated route projection
starts hiding the real provider-specific config.
`Route.with(...)` patch semantics should be boring and explicit:
- Omitted fields inherit from the original route.
- `endpoint` patches merge with the existing endpoint, so overriding `baseURL`
keeps the existing `path`.
- `endpoint.query` merges by default; later values win.
- `auth` replaces.
- `headers` merge by default; undefined values are omitted.
- `id` is optional in patches. Route ids are diagnostic/provider API labels, not
global runtime registry keys.
1. **Route**
- route id
- provider id
- protocol
- body schema
- body builder
- stream event schema
- parser/state machine
- transport
- method / IO shape
- framing
- request preparation
- constants when unconfigured; functions only when configured
- endpoint
- base URL
- static path
- body/model-derived path
- query params
- auth
- bearer
- custom header
- multiple credentials
- SigV4
- none
- defaults
- headers
- generation defaults
- provider options
- limits
2. **Provider Facade**
- default configured provider instance
- provider-specific `.configure(...)`
- plain object/function facade over one or more routes
- top-level export only when it represents one coherent config surface
- no passive `Provider.make(...)` wrapper unless it gains runtime behavior
3. **Model Selector**
- route/provider-owned selector
- accepts model id only
- returns executable models
- does not accept endpoint/auth/deployment overrides
4. **Language Model**
- model id
- route value
- provider id
- configured route value at selection time
5. **LLM Request**
- model
- messages/tools
- generation/cache/reasoning/response-format options
- request-level HTTP overlays for per-request headers/query/body additions,
not provider endpoint/auth reconfiguration
6. **Compile**
- read route from model
- merge route defaults and request overrides
- build final URL from route endpoint
- apply auth from the configured route
- build body with protocol
- execute with transport and parse with protocol
## Provider Facade Shape
The provider abstraction is a facade over configured routes, not the runtime
execution mechanism:
```ts
type ProviderFacade<APIs, Config> = {
readonly id: ProviderID
readonly model: (id: string) => LanguageModel
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs
```
Manual construction is fine and should be the default until duplication earns a
helper:
```ts
export const OpenAI = {
id: openAIProvider,
model: openAIResponses.model,
responses: openAIResponses.model,
chat: openAIChat.model,
configure: configureOpenAI,
} satisfies ProviderFacade<
{
responses: (id: string) => LanguageModel
chat: (id: string) => LanguageModel
},
OpenAIConfig
>
```
If several providers repeat the same projection from route values to model
methods, the helper can stay deliberately tiny:
```ts
const configureOpenAI = (input: OpenAIConfig = {}) =>
Provider.define({
id: openAIProvider,
routes: {
responses: openAIResponses.with(openAIConfig(input)),
chat: openAIChat.with(openAIConfig(input)),
},
default: "responses",
configure: configureOpenAI,
})
export const OpenAI = configureOpenAI()
```
`Provider.define(...)` would only project route methods and preserve types:
```ts
OpenAI.model("gpt-4o")
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.configure({ apiKey }).responses("gpt-4o")
```
It must not register routes, select routes dynamically, or participate in
execution. Execution still reads the route value carried by the model.
## Ideal Call Sites
Define concrete routes for a native provider, then project them through a
provider facade:
```ts
const openAIProvider = ProviderID.make("openai")
const openAIResponses = Route.make({
id: "openai-responses",
provider: openAIProvider,
protocol: OpenAIResponses.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/responses",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIChat = Route.make({
id: "openai-chat",
provider: openAIProvider,
protocol: OpenAIChat.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/chat/completions",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
headers: {
"OpenAI-Organization": input.organization,
"OpenAI-Project": input.project,
},
})
const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input))
return {
id: openAIProvider,
responses: responses.model,
chat: chat.model,
model: responses.model,
configure: configureOpenAI,
}
}
export const OpenAI = configureOpenAI()
```
Specialize it functionally for concrete providers:
```ts
const deepSeekProvider = ProviderID.make("deepseek")
const deepseekChat = openAIChat.with({
id: "deepseek-chat",
provider: deepSeekProvider,
endpoint: {
baseURL: "https://api.deepseek.com/v1",
},
auth: Auth.envBearer("DEEPSEEK_API_KEY"),
})
const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => {
const route = deepseekChat.with({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
})
return {
id: deepSeekProvider,
model: route.model,
configure: configureDeepSeek,
}
}
export const DeepSeek = {
id: deepSeekProvider,
model: deepseekChat.model,
configure: configureDeepSeek,
}
```
Provider-specific configuration happens before model selection:
```ts
const deepseek = DeepSeek.configure({
endpoint: {
baseURL: "https://proxy.example.com/v1",
},
auth: Auth.bearer(apiKey),
})
const model = deepseek.model("deepseek-chat")
```
Final request call site stays boring:
```ts
const response =
yield *
LLM.generate(
LLM.request({
model: DeepSeek.model("deepseek-chat"),
prompt: "Hello.",
}),
)
```
For direct provider-facade calls, Responses has one semantic model and route:
```ts
OpenAI.responses("gpt-4o")
```
The package-like OpenAI Responses entrypoint has the same transport-neutral
`model(...)` contract:
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
model("gpt-4o", { apiKey })
```
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
while sharing project/location resolution and ADC authentication internally:
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project, location: "global" })
```
The client does not require a different public layer for WebSocket execution.
Responses routes use HTTP by default, and callers may pass a channel executor per
call. Routes without channel support simply ignore that execution capability.
Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects
deployment ids with pure model selectors:
```ts
const azureProvider = ProviderID.make("azure")
const azureResponses = openAIResponses.with({
id: "azure-openai-responses",
provider: azureProvider,
auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
const configureAzure = (input: AzureConfig = {}) => {
const route = azureResponses.with({
endpoint: {
baseURL:
input.baseURL ??
Endpoint.envBaseURL(
"AZURE_RESOURCE_NAME",
(resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`,
),
query: { "api-version": input.apiVersion ?? "v1" },
},
auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
return {
id: azureProvider,
model: route.model,
responses: route.model,
configure: configureAzure,
}
}
export const Azure = configureAzure()
const azure = Azure.configure({
resourceName: "my-resource",
apiVersion: "v1",
})
const model = azure.responses("my-deployment")
```
Default provider facades are only valid when required configuration has a lazy
default source. `Azure.responses("my-deployment")` can be valid if endpoint
resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed
configuration error when missing. If a provider has no sensible lazy default,
do not expose a default model selector; expose only a configured entrypoint.
Cloudflare AI Gateway and Workers AI are separate product facades because their
configuration surfaces differ. Do not make a root `Cloudflare.configure(...)`
pretend there is one coherent Cloudflare provider configuration:
```ts
const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway")
const cloudflareOpenAIChat = openAIChat.with({
id: "cloudflare-ai-gateway-openai-chat",
provider: cloudflareProvider,
auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()),
})
const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => {
const route = cloudflareOpenAIChat.with({
endpoint: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`,
},
auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)),
})
return {
id: cloudflareProvider,
model: (modelID: string) => route.model({ id: modelID }),
configure: configureCloudflareAIGateway,
}
}
export const CloudflareAIGateway = {
id: cloudflareProvider,
configure: configureCloudflareAIGateway,
}
const gateway = CloudflareAIGateway.configure({
accountId: "account",
gatewayId: "gateway",
gatewayApiKey,
apiKey,
})
const model = gateway.model("openai/gpt-4o")
```
If a Cloudflare product gains a full lazy env default, it can expose a direct
selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing
account/gateway configuration unrepresentable.
opencode's dynamic runtime should construct executable models at its app
boundary instead of exposing a giant unstructured public model constructor or a
generic dynamic resolver:
```ts
const model =
providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
```
That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. Transport selection remains execution policy: a Session
or other caller may pass a WebSocket channel executor per call without changing
the model constructed by this boundary.
## Competitive Shape
This follows the strongest parts of adjacent libraries:
- AI SDK: configured provider instances expose provider-specific model methods.
- Effect AI: executable models carry provider requirements and can be resolved by
an app boundary.
- LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the
app boundary, not in the typed public provider API or a global runtime
resolver.
- LangChain/LlamaIndex: constructor-style config plus model id is convenient,
but we avoid making model selection also configure endpoint/auth.
The chosen split is:
```txt
Route = execution mechanics
Provider facade = configured route group
LanguageModel = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call
```
## What This Removes
- No `Provider.make(...)` as a core abstraction.
- No `Provider.make(...)` wrapper just to bind an id to model functions. Use a
branded provider id constant and a plain exported provider facade.
- No `Deployment.define(...)` unless future examples force it.
- No global route registry as the normal execution path.
- No import side effects required before a model can execute.
- No duplicate `provider.id` object when selected models already carry provider
id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.
- No transport setting on a provider or executable model. OpenAI Responses uses
HTTP by default and accepts an optional per-call channel executor as execution policy.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
identity stays separate and cannot execute on its own.
## Implementation Todo
- [x] Replace the current executable `ModelRef` with `LanguageModel`.
- [x] Change `LanguageModel.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context.
- [x] Change route model selectors so `route.model(id)` returns an executable
model with the route value attached, not a globally registered route id.
- [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper;
configured route instances own model selection.
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
configure endpoint/auth through `route.with(...)` or provider facades before
calling `.model(...)`.
- [x] Remove request-shaping defaults from `LanguageModel`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.
- [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the
current split where host/query live on the model and path lives in route
transport setup.
- [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge,
query merge, header merge, auth replacement, and optional diagnostic id.
- [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
optional per-call channel execution without changing route identity.
- [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)` and `.chat(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as
`CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root
config surface unless one product actually exists.
- [x] Migrate remaining built-in provider facades one at a time so configuration
happens before model selection and selectors accept only ids:
xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic,
Google/Gemini, and Amazon Bedrock now use configured facades such as
`Provider.configure(options).model(id)` with named selectors where needed.
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not
yet painful.
- [x] Keep executable model construction transport-neutral at the Session boundary;
Session-scoped execution policy supplies channel capability separately.
- [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route
selection.
- [ ] Remove compatibility exports or stale docs only after internal call sites
are migrated; do not keep duplicate constructor paths without an external
compatibility need.
## Open Questions
- Default facades with required setup: should providers like Azure and Bedrock
expose default model selectors only when all required setup has lazy env or
credential-chain defaults? If not, omit the default selector so missing config
is impossible at the type/API level.
- Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed
auth produce typed configuration/authentication errors at compile/prepare time
or only when executing the transport?
- `Route.with(...)` clearing semantics: endpoint/query/header patches merge by
default, but what is the explicit way to remove an inherited value?
- Provider facade helper: keep plain objects until duplication hurts, or add a
tiny `Provider.define(...)` immediately to enforce shape and method projection?
- Auth shape: should auth stay as today's composable `Auth`, or split into an
auth placement/strategy and credential sources?
- Naming: is `baseURL` still the right endpoint field name, or should it be
`origin` / `urlPrefix` to clarify that route `path` is appended?
+3 -3
View File
@@ -22,7 +22,7 @@ const model = OpenAI.configure({
apiKey, apiKey,
generation: { maxTokens: 160 }, generation: { maxTokens: 160 },
providerOptions: { providerOptions: {
openai: { store: false }, store: false,
}, },
}).model("gpt-4o-mini") }).model("gpt-4o-mini")
@@ -34,7 +34,7 @@ const model = OpenAI.configure({
// - `generation`: common controls such as max tokens, temperature, topP/topK, // - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences. // penalties, seed, and stop sequences.
// - `promptCacheKey`: stable cache affinity for protocols that support it. // - `promptCacheKey`: stable cache affinity for protocols that support it.
// - `providerOptions`: namespaced provider-native behavior. For example, // - `providerOptions`: model-typed provider-native behavior. For example,
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or // OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
// OpenRouter routing/reasoning. // OpenRouter routing/reasoning.
// - `http`: last-resort serializable overlays for final request body, headers, // - `http`: last-resort serializable overlays for final request body, headers,
@@ -188,7 +188,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
}, },
}) })
// An route is the runnable binding for that protocol. It adds the deployment // A route is the runnable binding for that protocol. It adds the deployment
// axes that the protocol deliberately does not know: URL, auth, and framing. // axes that the protocol deliberately does not know: URL, auth, and framing.
const FakeAdapter = Route.make({ const FakeAdapter = Route.make({
id: "fake-echo", id: "fake-echo",
+3 -5
View File
@@ -5,8 +5,8 @@
// The default `"auto"` shape places breakpoints at the last tool definition, // The default `"auto"` shape places breakpoints at the last tool definition,
// the first and last distinct system parts, and the conversation tail. This // the first and last distinct system parts, and the conversation tail. This
// exposes reusable tool, base-agent, project, and session prefixes while // exposes reusable tool, base-agent, project, and session prefixes while
// advancing the tail after each tool result keeps the previous cache entry // advancing the tail after each tool result keeps recent conversation prefixes
// within Anthropic's 20-block lookback during long agent turns. // reusable during long agent runs.
// //
// Manual `cache: CacheHint` placements on individual parts are preserved and // Manual `cache: CacheHint` placements on individual parts are preserved and
// count against the four-breakpoint budget; auto only fills remaining slots. // count against the four-breakpoint budget; auto only fills remaining slots.
@@ -23,9 +23,7 @@ const NONE: CachePolicyObject = {}
const BREAKPOINT_CAP = 4 const BREAKPOINT_CAP = 4
// Resolution rules: // Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it: // - undefined → "auto" — caching is on by default.
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + first/last system + final message boundary. // - "auto" → tools + first/last system + final message boundary.
// - "none" → no auto placement; manual `CacheHint`s still flow. // - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for. // - object form → exactly what the caller asked for.
@@ -16,7 +16,6 @@ import {
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderOptions,
type ProviderMetadata, type ProviderMetadata,
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
@@ -32,6 +31,7 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages" const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages" export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
export type ThinkingInput = export type ThinkingInput =
| { | {
@@ -52,9 +52,7 @@ export interface OptionsInput {
readonly effort?: string readonly effort?: string
} }
export type ProviderOptionsInput = ProviderOptions & { export type ProviderOptionsInput = OptionsInput
readonly anthropic?: OptionsInput
}
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
@@ -593,7 +591,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}) })
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) { const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions?.anthropic const input = request.providerOptions
return { return {
thinking: yield* resolveThinking(input?.thinking), thinking: yield* resolveThinking(input?.thinking),
effort: typeof input?.effort === "string" ? input.effort : undefined, effort: typeof input?.effort === "string" ? input.effort : undefined,
@@ -627,7 +625,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system → // Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers // messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first. // over-mark we keep their tool hints and shed the message-tail ones first.
@@ -666,7 +663,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools, tools,
tool_choice: toolChoice, tool_choice: toolChoice,
stream: true as const, stream: true as const,
max_tokens: generation?.maxTokens ?? outputLimit, max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
top_k: generation?.topK, top_k: generation?.topK,
@@ -1015,8 +1012,8 @@ const step = (state: ParserState, event: AnthropicEvent) => {
// ============================================================================= // =============================================================================
/** /**
* The Anthropic Messages protocol — request body construction, body schema, * The Anthropic Messages protocol — request body construction, body schema,
* and the streaming-event state machine. Used by native Anthropic Cloud and * and the streaming-event state machine shared by Anthropic-compatible and
* (once registered) Vertex Anthropic / Bedrock-hosted Anthropic passthrough. * Vertex-hosted Messages routes.
*/ */
export const protocol = Protocol.make({ export const protocol = Protocol.make({
id: ADAPTER, id: ADAPTER,
+6 -8
View File
@@ -12,7 +12,6 @@ import {
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderOptions,
type ProviderMetadata, type ProviderMetadata,
type TextPart, type TextPart,
type ToolCallPart, type ToolCallPart,
@@ -67,9 +66,7 @@ export interface OptionsInput {
} }
} }
export type ProviderOptionsInput = ProviderOptions & { export type ProviderOptionsInput = OptionsInput
readonly gemini?: OptionsInput
}
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
@@ -292,7 +289,9 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1) const previous = contents.at(-1)
if (previous?.role === "user") // Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] } contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] }) else contents.push({ role: "user", parts: [{ text: part.text }] })
continue continue
@@ -387,7 +386,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}) })
const resolveOptions = (request: LLMRequest) => { const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions?.gemini const input = request.providerOptions
const value = input?.thinkingConfig const value = input?.thinkingConfig
const thinkingConfig = { const thinkingConfig = {
thinkingBudget: thinkingBudget:
@@ -630,8 +629,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// ============================================================================= // =============================================================================
/** /**
* The Gemini protocol — request body construction, body schema, and the * The Gemini protocol — request body construction, body schema, and the
* streaming-event state machine. Used by Google AI Studio Gemini and (once * streaming-event state machine shared by Google AI Studio and Vertex Gemini.
* registered) Vertex Gemini.
*/ */
export const protocol = Protocol.make({ export const protocol = Protocol.make({
id: ADAPTER, id: ADAPTER,
+134 -55
View File
@@ -90,8 +90,11 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
export const InputItem = Schema.Union([ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText), content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase), phase: Schema.optionalKey(MessagePhase),
@@ -100,6 +103,7 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference, OpenResponsesItemReference,
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call"), type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
name: Schema.String, name: Schema.String,
arguments: Schema.String, arguments: Schema.String,
@@ -114,6 +118,8 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem = type LoweredInputItem =
| OpenResponsesInputItem | OpenResponsesInputItem
| { | {
readonly type: "message"
readonly id?: string
readonly role: "assistant" readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }> readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null readonly phase?: MessagePhase | null
@@ -127,8 +133,6 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }> summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null encrypted_content?: string | null
} }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({ export const Tool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
name: Schema.String, name: Schema.String,
@@ -140,6 +144,11 @@ export const Tool = Schema.Struct({
export const ToolChoice = Schema.Union([ export const ToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]), Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({
type: Schema.tag("allowed_tools"),
mode: Schema.Literals(["auto", "none", "required"]),
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
}),
]) ])
// Fields shared between the HTTP body and the WebSocket `response.create` // Fields shared between the HTTP body and the WebSocket `response.create`
@@ -153,6 +162,15 @@ export const coreFields = {
tools: optionalArray(Tool), tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice), tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema), service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String), prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema), include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
@@ -168,8 +186,12 @@ export const coreFields = {
}), }),
), ),
max_output_tokens: Schema.optional(Schema.Number), max_output_tokens: Schema.optional(Schema.Number),
max_tool_calls: Schema.optional(Schema.Int),
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
} }
const OpenResponsesBody = Schema.Struct({ const OpenResponsesBody = Schema.Struct({
@@ -279,6 +301,20 @@ export const Event = Schema.StructWithRest(
) )
export type Event = Schema.Schema.Type<typeof Event> export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension { export interface Extension {
readonly id: string readonly id: string
readonly name: string readonly name: string
@@ -331,7 +367,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.responses(inputSchema), parameters: ToolSchemaProjection.responses(inputSchema),
// TODO: Read this from Responses tool options so direct LLM callers can opt into strict schemas. // The common tool definition does not currently express Responses strict-schema policy.
strict: false, strict: false,
} }
}) })
@@ -344,34 +380,42 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }), tool: (toolName) => ({ type: "function" as const, name: toolName }),
}) })
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
type: "function_call", const metadata = providerMetadata?.[providerMetadataKey]
call_id: part.id, return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
name: part.name, ? metadata.itemId
arguments: ProviderShared.encodeJson(part.input), : undefined
}) }
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => { const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0) const id = itemID(part.providerMetadata, providerMetadataKey)
return undefined if (!ProviderShared.isRecord(metadata) || !id) return undefined
const encryptedContent = const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent ? metadata.reasoningEncryptedContent
: undefined : undefined
return { return {
type: "reasoning", type: "reasoning",
id: metadata.itemId, id,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent, encrypted_content: encryptedContent,
} }
} }
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => { const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey] return itemID(part.providerMetadata, providerMetadataKey)
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
} }
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -439,14 +483,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const message of request.messages) { for (const message of request.messages) {
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message) input.push({
const previous = input.at(-1) role: "developer",
if (previous && "role" in previous && previous.role === "user") content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
input[input.length - 1] = { })
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
}
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
continue continue
} }
@@ -460,24 +500,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") { if (message.role === "assistant") {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {} const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>() const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>() const hostedToolReferences = new Set<string>()
const flushText = () => { const flushText = () => {
if (content.length === 0) return if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<
(groups, part) => { Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
const metadata = part.providerMetadata?.[providerMetadataKey] >((groups, part) => {
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined const metadata = part.providerMetadata?.[providerMetadataKey]
const group = groups.at(-1) const id = itemID(part.providerMetadata, providerMetadataKey)
if (group && group.phase === phase) group.parts.push(part) const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
else groups.push({ phase, parts: [part] }) const group = groups.at(-1)
return groups if (group && group.id === id && group.phase === phase) group.parts.push(part)
}, else groups.push({ id, phase, parts: [part] })
[], return groups
) }, [])
input.push( input.push(
...groups.map((group) => ({ ...groups.map((group) => ({
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const, role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })), content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }), ...(group.phase === undefined ? {} : { phase: group.phase }),
@@ -506,19 +548,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content existing.encrypted_content = reasoning.encrypted_content
continue continue
} }
const replay = { reasoningItems[reasoning.id] = reasoning
type: reasoning.type, input.push(reasoning)
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
flushText() flushText()
if (part.providerExecuted === true) continue if (part.providerExecuted === true) continue
input.push(lowerToolCall(part)) input.push(lowerToolCall(part, providerMetadataKey))
continue continue
} }
if (part.type === "tool-result" && part.providerExecuted === true) { if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -573,6 +610,12 @@ const lowerOptions = (request: LLMRequest) => {
return { return {
...(options.instructions ? { instructions: options.instructions } : {}), ...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}), ...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary ...(options.reasoningEffort || options.reasoningSummary
@@ -580,6 +623,19 @@ const lowerOptions = (request: LLMRequest) => {
: {}), : {}),
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}), ...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}), ...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
type: "allowed_tools" as const,
mode: allowed.mode,
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
} }
} }
@@ -602,11 +658,15 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
), ),
), ),
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined, tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const, stream: true as const,
max_output_tokens: generation?.maxTokens, max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request), ...lowerOptions(request),
} }
}) })
@@ -675,7 +735,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events] return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
} }
@@ -904,7 +964,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle, state.lifecycle,
events, events,
item.id, item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }), providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
), ),
messageItems, messageItems,
messagePhases, messagePhases,
@@ -917,7 +977,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id] const tools = state.tools[item.id]
? state.tools ? 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,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
const result = const result =
item.arguments === undefined item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id) ? yield* ToolStream.finish(state.id, tools, item.id)
@@ -968,11 +1032,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult return [state, NO_EVENTS] satisfies StepResult
}) })
const onResponseFinish = (state: ParserState, event: Event): StepResult => { const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const events: LLMEvent[] = [] // Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, { const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: { reason: {
normalized: mapFinishReason(event, state.hasFunctionCall), normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason, raw: event.response?.incomplete_details?.reason,
}, },
usage: mapUsage(event.response?.usage, state.providerMetadataKey), usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -984,8 +1056,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
}) })
: undefined, : undefined,
}) })
return [{ ...state, lifecycle }, events] return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
} })
// Build a single human-readable message from whatever the provider supplied. // Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see // When both code and message are present, prefix the code so consumers see
@@ -1027,6 +1099,14 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id), : onOutputTextDone(state, event, event.item_id),
) )
} }
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") { if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id)) return Effect.succeed(onReasoningDelta(state, event, event.item_id))
@@ -1054,8 +1134,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`) return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event) return onOutputItemDone(state, event)
} }
if (event.type === "response.completed" || event.type === "response.incomplete") if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`) if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") if (event.type === "error")
return decodeKnownErrorEvent(event).pipe( return decodeKnownErrorEvent(event).pipe(
+14 -2
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js" import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat" const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]) const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions" export const PATH = "/chat/completions"
@@ -194,6 +194,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest( const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
content: optionalNull(Schema.String), content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String), reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String), reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String), reasoning_text: optionalNull(Schema.String),
@@ -709,6 +710,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField) const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent = const hasLateContent =
Boolean(delta?.content) || Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined || reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) || (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))
@@ -728,7 +730,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if ( else if (
reasoningDetailsObserved && reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") && !lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0) (Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
) )
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata) lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0") const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -743,6 +745,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
} }
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use // Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks. // batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) { for (const [position, tool] of toolDeltas.entries()) {
@@ -42,6 +42,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })), content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)), phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
@@ -121,7 +123,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
: yield* Effect.forEach(request.tools, (tool) => : yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
), ),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined, tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody } satisfies OpenAIResponsesBody
}) })
@@ -260,7 +263,7 @@ export const route = Route.make({
endpoint, endpoint,
auth, auth,
transport, transport,
defaults: { providerOptions: { openai: { store: false } } }, defaults: { providerOptions: { store: false } },
}) })
export * as OpenAIResponses from "./openai-responses.js" export * as OpenAIResponses from "./openai-responses.js"
+8 -14
View File
@@ -41,12 +41,10 @@ export interface ToolAccumulator {
* when at least one is defined. Returns `undefined` when neither input nor * when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`. * output is known so routes don't publish a misleading `0`.
* *
* Under the additive `AI.Usage` contract, `inputTokens` and `outputTokens` * Under the inclusive `AI.Usage` contract, `inputTokens` includes cached input
* are the non-cached input and visible output only. The provider-supplied * and `outputTokens` includes reasoning. Protocol mappers normalize those
* `total` is the source of truth when present; the computed fallback * inclusive values before calling this helper. The provider-supplied total is
* under-counts cache and reasoning by design and exists mainly so * the source of truth when present; otherwise their sum is the canonical total.
* Anthropic-style providers (which don't surface a total) still get a
* sensible aggregate on the input + output axes.
*/ */
export const totalTokens = ( export const totalTokens = (
inputTokens: number | undefined, inputTokens: number | undefined,
@@ -67,7 +65,7 @@ export const totalTokens = (
* *
* If `total` is `undefined`, returns `undefined` (we don't fabricate * If `total` is `undefined`, returns `undefined` (we don't fabricate
* counts). If `subtrahend` is `undefined`, returns `total` unchanged. The * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
* provider-native breakdown stays available on `Usage.native` for debugging. * provider-native breakdown stays available on `Usage.providerMetadata` for debugging.
*/ */
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => { export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
if (total === undefined) return undefined if (total === undefined) return undefined
@@ -199,8 +197,8 @@ export const errorText = (error: unknown) => {
/** /**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream * decoder, and drops empty / `[DONE]` keep-alive events so the protocol event
* `decodeChunk` sees one JSON string per element. The SSE channel emits a * schema sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't * `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output * implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`. * errors so the public error channel stays `AIError`.
@@ -216,11 +214,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.St
) )
/** /**
* Canonical invalid-request constructor. Lift one-line `const invalid = * Canonical invalid-request constructor shared by protocol lowering.
* (message) => invalidRequest(message)` aliases out of every
* route so the error constructor lives in one place. If we ever extend
* `InvalidRequestReason` with route context or trace metadata, the change
* lands here.
*/ */
export const invalidRequest = (message: string) => export const invalidRequest = (message: string) =>
new AIError({ new AIError({
@@ -4,7 +4,7 @@ import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately // Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock // after the content the caller wants treated as a cacheable prefix. Bedrock
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. // accepts optional `ttl: "5m" | "1h"` on cachePoint.
export const CachePointBlock = Schema.Struct({ export const CachePointBlock = Schema.Struct({
cachePoint: Schema.Struct({ cachePoint: Schema.Struct({
type: Schema.tag("default"), type: Schema.tag("default"),
@@ -13,9 +13,8 @@ export const CachePointBlock = Schema.Struct({
}) })
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock> export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages // Callers pass a shared counter through every `block()` call site so the
// API. Callers pass a shared counter through every `block()` call site so the // four-breakpoint budget is respected across `system`, `messages`, and `tools`.
// budget is respected across `system`, `messages`, and `tools`.
export const BEDROCK_BREAKPOINT_CAP = 4 export const BEDROCK_BREAKPOINT_CAP = 4
export type { Breakpoints } from "./cache.js" export type { Breakpoints } from "./cache.js"
+3 -6
View File
@@ -1,6 +1,4 @@
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock // Shared counter and TTL mapping for provider cache-marker lowering.
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
// TTL buckets, so the counter and TTL mapping live here.
export interface Breakpoints { export interface Breakpoints {
remaining: number remaining: number
@@ -9,8 +7,7 @@ export interface Breakpoints {
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the // Requests of at least one hour use the explicit `"1h"` bucket; shorter
// provider default 5m). Anthropic & Bedrock both treat anything shorter than // requests omit the wire TTL and use the provider default.
// an hour as 5m.
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
@@ -1,5 +1,19 @@
import { Schema } from "effect" import { Option, Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js" import type { LLMRequest } from "../../schema/index.js"
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
export const TextVerbosity = Schema.declare<TextVerbosity>(
(value): value is TextVerbosity => typeof value === "string",
{ title: "TextVerbosity" },
)
export const ResponseIncludables = [ export const ResponseIncludables = [
"file_search_call.results", "file_search_call.results",
@@ -11,52 +25,67 @@ export const ResponseIncludables = [
"reasoning.encrypted_content", "reasoning.encrypted_content",
"message.output_text.logprobs", "message.output_text.logprobs",
] as const ] as const
export type ResponseIncludable = (typeof ResponseIncludables)[number] export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] export type ServiceTier = (typeof ServiceTiers)[number]
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"]) export const Truncations = ["auto", "disabled"] as const
const INCLUDABLES = new Set<string>(ResponseIncludables) export type Truncation = (typeof Truncations)[number]
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables) export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" },
)
export const ServiceTierSchema = Schema.Literals(ServiceTiers) export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export const TruncationSchema = Schema.Literals(Truncations)
export interface Resolved { export const AllowedTools = Schema.Struct({
readonly instructions?: string toolNames: Schema.Array(Schema.String),
readonly store?: boolean mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
readonly reasoningEffort?: string })
readonly reasoningSummary?: "auto" | "concise" | "detailed" export type AllowedTools = typeof AllowedTools.Type
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity> export const StreamOptions = Schema.Struct({
readonly serviceTier?: ServiceTier includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
} }
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => { export const resolve = (request: LLMRequest): Resolved => {
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"] const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
const include = Array.isArray(input?.include) if (!input) return {}
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
: []
const reasoningSummary = input?.reasoningSummary
return { return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined, ...input,
store: typeof input?.store === "boolean" ? input.store : undefined, include: input.include?.length ? input.include : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined, allowedTools:
reasoningSummary: input.allowedTools && input.allowedTools.toolNames.length > 0
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed" ? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
? reasoningSummary
: undefined, : undefined,
include: include.length > 0 ? include : undefined,
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
} }
} }
@@ -1,8 +1,9 @@
import { ReasoningEfforts } from "../../schema/index.js"
import { OpenResponsesOptions } from "./open-responses-options.js" import { OpenResponsesOptions } from "./open-responses-options.js"
export const OpenAIReasoningEfforts = ReasoningEfforts export const OpenAIReasoningEfforts = OpenResponsesOptions.ReasoningEfforts
export type OpenAIReasoningEffort = string export type OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosities = OpenResponsesOptions.TextVerbosities
export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this // Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`. // in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -12,7 +13,7 @@ export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
-5
View File
@@ -4,11 +4,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>> readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>> readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
} }
export interface Definition< export interface Definition<
@@ -90,7 +90,6 @@ const config = (settings: Settings): Config => {
credentials: settings.credentials, credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
region: settings.region, region: settings.region,
} }
@@ -62,7 +62,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
generation: settings.topP === undefined ? undefined : { topP: settings.topP }, generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
region: settings.region, region: settings.region,
}).model(modelID) }).model(modelID)
} }
@@ -68,7 +68,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
-1
View File
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
} }
-1
View File
@@ -126,7 +126,6 @@ const config = (settings: Settings): Config => {
apiVersion: settings.apiVersion, apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls, useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
@@ -75,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location, location: settings.location,
project: settings.project, project: settings.project,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
@@ -110,7 +110,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location, location: settings.location,
project: settings.project, project: settings.project,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
@@ -27,7 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({ const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses", id: "google-vertex-responses",
provider: id, provider: id,
providerOptions: { openresponses: { store: false } }, providerOptions: { store: false },
}) })
export const routes = [route] export const routes = [route]
@@ -80,7 +80,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location, location: settings.location,
project: settings.project, project: settings.project,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
+3 -6
View File
@@ -6,16 +6,14 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js" import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js" import { Framing } from "../route/framing.js"
import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema/index.js" import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js" import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput { export interface GeminiOptionsInput extends Gemini.OptionsInput {
readonly labels?: Readonly<Record<string, string>> readonly labels?: Readonly<Record<string, string>>
} }
export type GeminiProviderOptionsInput = ProviderOptions & { export type GeminiProviderOptionsInput = GeminiOptionsInput
readonly gemini?: GeminiOptionsInput
}
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -40,7 +38,7 @@ export type Settings = ProviderPackage.Settings &
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request) const body = yield* Gemini.protocol.body.from(request)
const value = request.providerOptions?.gemini?.labels const value = request.providerOptions?.labels
const labels = ProviderShared.isRecord(value) const labels = ProviderShared.isRecord(value)
? Object.fromEntries( ? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
@@ -121,7 +119,6 @@ export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsIn
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location, location: settings.location,
project: settings.project, project: settings.project,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
-1
View File
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
@@ -1,19 +1,6 @@
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js" import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
export interface OpenResponsesOptionsInput { export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
readonly [key: string]: unknown export type OpenResponsesProviderOptionsInput = OpenResponsesOptionsInput
readonly instructions?: string
readonly store?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
}
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
}
export * as OpenResponsesProviderOptions from "./open-responses-options.js" export * as OpenResponsesProviderOptions from "./open-responses-options.js"
@@ -55,7 +55,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL: string readonly baseURL: string
readonly provider?: string readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -73,8 +74,8 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
export const baseten = define(profiles.baseten) export const baseten = define(profiles.baseten)
+5 -8
View File
@@ -1,20 +1,17 @@
import type { ProviderOptions } from "../schema/index.js" import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import { mergeProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js" import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js" export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIProviderOptionsInput = ProviderOptions & { export type OpenAIProviderOptionsInput = OpenAIOptionsInput
readonly openai?: OpenAIOptionsInput
}
const definedEntries = (input: Record<string, unknown>) => const definedEntries = (input: Record<string, unknown>) =>
Object.entries(input).filter((entry) => entry[1] !== undefined) Object.entries(input).filter((entry) => entry[1] !== undefined)
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => { const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
const openai = Object.fromEntries( const result = Object.fromEntries(
definedEntries({ definedEntries({
store: options?.store, store: options?.store,
reasoningEffort: options?.reasoningEffort, reasoningEffort: options?.reasoningEffort,
@@ -24,8 +21,8 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
serviceTier: options?.serviceTier, serviceTier: options?.serviceTier,
}), }),
) )
if (Object.keys(openai).length === 0) return undefined if (Object.keys(result).length === 0) return undefined
return { openai } return result
} }
export const gpt5DefaultOptions = ( export const gpt5DefaultOptions = (
-1
View File
@@ -124,7 +124,6 @@ const config = (settings: Settings): Config => {
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers, headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
} }
+3 -6
View File
@@ -4,7 +4,7 @@ import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js" import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema/index.js" import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js" import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -71,9 +71,7 @@ export interface OpenRouterOptions {
}> }>
} }
export type OpenRouterProviderOptionsInput = ProviderOptions & { export type OpenRouterProviderOptionsInput = OpenRouterOptions
readonly openrouter?: OpenRouterOptions
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
@@ -120,7 +118,7 @@ export const protocol = Protocol.make({
return { return {
...body, ...body,
messages, messages,
...bodyOptions(request.providerOptions?.openrouter), ...bodyOptions(request.providerOptions),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody } as OpenRouterBody
}), }),
@@ -202,6 +200,5 @@ export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptio
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers, headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
+3 -6
View File
@@ -1,7 +1,7 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js" import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema/index.js" import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js" import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -12,9 +12,7 @@ import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai") export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = ProviderOptions & { export type XAIProviderOptionsInput = OpenAIOptionsInput
readonly xai?: OpenAIOptionsInput
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
@@ -37,7 +35,7 @@ const responsesRoute = Route.make({
protocol: OpenAIResponses.protocol, protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport, transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { xai: { store: false } } }, defaults: { providerOptions: { store: false } },
}) })
const chatRoute = Route.make({ const chatRoute = Route.make({
@@ -103,7 +101,6 @@ export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers, headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } }, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
export const responses = provider.responses export const responses = provider.responses
-4
View File
@@ -16,7 +16,6 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
LanguageModel, LanguageModel,
LanguageModelLimits,
LLMEvent, LLMEvent,
InvalidProviderOutputReason, InvalidProviderOutputReason,
ProviderID, ProviderID,
@@ -74,7 +73,6 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults { export interface RouteDefaults {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions readonly http?: HttpOptions
@@ -82,7 +80,6 @@ export interface RouteDefaults {
export interface RouteDefaultsInput { export interface RouteDefaultsInput {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -119,7 +116,6 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base, ...base,
...patch, ...patch,
headers, headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions( http: mergeHttpOptions(
+2 -2
View File
@@ -13,8 +13,8 @@ import type { AIError } from "../schema/index.js"
* - AWS event stream — length-prefixed binary frames with CRC checksums. * - AWS event stream — length-prefixed binary frames with CRC checksums.
* Each emitted frame is one parsed binary event record. * Each emitted frame is one parsed binary event record.
* *
* The frame type is opaque to this layer; the protocol's `decode` step turns * The frame type is opaque to this layer; the protocol's event schema decodes
* a frame into a typed chunk. * each frame before its state machine handles it.
*/ */
export interface Definition<Frame> { export interface Definition<Frame> {
readonly id: string readonly id: string
+1 -2
View File
@@ -73,8 +73,7 @@ export interface ProtocolStream<Frame, Event, State> {
* *
* Provider implementations should usually call `Protocol.make({ ... })` * Provider implementations should usually call `Protocol.make({ ... })`
* without explicit type arguments; the schemas and parser functions are the * without explicit type arguments; the schemas and parser functions are the
* source of truth. The constructor remains as the public seam for future * source of truth.
* cross-cutting concerns such as tracing or instrumentation.
*/ */
export const make = <Body, Frame, Event, State>( export const make = <Body, Frame, Event, State>(
input: Protocol<Body, Frame, Event, State>, input: Protocol<Body, Frame, Event, State>,
+2 -1
View File
@@ -1,6 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids.js" import { ModelID, ProviderID, RouteID } from "./ids.js"
import { ProviderMetadata } from "./messages.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
+15 -2
View File
@@ -1,8 +1,21 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids.js" import { LLM } from "@opencode-ai/schema/llm"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages.js" import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
ProviderMetadata,
ToolCallPart,
ToolOutput,
ToolResultPart,
ToolResultValue,
type ContentPart,
} from "./messages.js"
import { ProviderFailureClassification } from "./errors.js" import { ProviderFailureClassification } from "./errors.js"
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export { ProviderMetadata } from "./messages.js"
/** /**
* Token usage reported by an LLM provider. * Token usage reported by an LLM provider.
* *
-20
View File
@@ -1,8 +1,4 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
/** Stable string identifier for a protocol implementation. */ /** Stable string identifier for a protocol implementation. */
export const ProtocolID = Schema.String export const ProtocolID = Schema.String
@@ -26,19 +22,3 @@ export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID> export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
+9 -1
View File
@@ -1,16 +1,24 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids.js"
import { import {
CacheHint, CacheHint,
CachePolicy, CachePolicy,
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
JsonSchema,
LanguageModelSchema, LanguageModelSchema,
ProviderOptions, ProviderOptions,
} from "./options.js" } from "./options.js"
import { isRecord } from "../utils/record.js" import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "LLM.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
const systemPartSchema = Schema.Struct({ const systemPartSchema = Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
text: Schema.String, text: Schema.String,
+8 -33
View File
@@ -1,8 +1,11 @@
import { Schema } from "effect" import { Schema } from "effect"
import { JsonSchema, ModelID, ProviderID } from "./ids.js" import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js" import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js" import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
export const mergeJsonRecords = ( export const mergeJsonRecords = (
...items: ReadonlyArray<Record<string, unknown> | undefined> ...items: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
@@ -33,22 +36,12 @@ const mergeStringRecords = (
return Object.keys(result).length === 0 ? undefined : result return Object.keys(result).length === 0 ? undefined : result
} }
export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) export const ProviderOptions = Schema.Record(Schema.String, Schema.Unknown)
export type ProviderOptions = Schema.Schema.Type<typeof ProviderOptions> export type ProviderOptions = Schema.Schema.Type<typeof ProviderOptions>
export const mergeProviderOptions = ( export const mergeProviderOptions = (
...items: ReadonlyArray<ProviderOptions | undefined> ...items: ReadonlyArray<ProviderOptions | undefined>
): ProviderOptions | undefined => { ): ProviderOptions | undefined => mergeJsonRecords(...items)
const result: Record<string, Record<string, unknown>> = {}
for (const item of items) {
if (!item) continue
for (const [provider, options] of Object.entries(item)) {
const merged = mergeJsonRecords(result[provider], options)
if (merged) result[provider] = merged
}
}
return Object.keys(result).length === 0 ? undefined : result
}
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
body: Schema.optional(JsonSchema), body: Schema.optional(JsonSchema),
@@ -121,22 +114,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined return Object.values(result).some((value) => value !== undefined) ? result : undefined
} }
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({ export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions), generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
@@ -146,7 +124,6 @@ export namespace LanguageModelDefaults {
export type Input = export type Input =
| LanguageModelDefaults | LanguageModelDefaults
| { | {
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -156,7 +133,6 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => { export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({ return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions, providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http), http: input.http === undefined ? undefined : HttpOptions.make(input.http),
@@ -269,10 +245,9 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
// Auto-placement policy for prompt caching. The protocol-neutral lowering step // Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the // reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as // per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places // usual. `"auto"` is the default for agent loops — it places
// breakpoints at the last tool definition, the first and last distinct system // breakpoints at the last tool definition, the first and last distinct system
// parts, and the conversation tail. The rolling message breakpoint keeps a // parts, and the conversation tail so recent prefixes remain reusable during
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
// tool loops. // tool loops.
// //
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
+13 -3
View File
@@ -7,6 +7,7 @@ import {
type FinishReasonDetails, type FinishReasonDetails,
type AIError, type AIError,
type LLMRequest, type LLMRequest,
type ProviderMetadata,
type UsageInput, type UsageInput,
} from "./schema/index.js" } from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect" import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
@@ -33,13 +34,22 @@ export interface LayerOptions {
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = ( export const complete = (
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput }, options: {
readonly reason: FinishReasonDetails
readonly usage?: UsageInput
readonly providerMetadata?: ProviderMetadata
},
...events: readonly LLMEvent[] ...events: readonly LLMEvent[]
) => [ ) => [
LLMEvent.stepStart({ index: 0 }), LLMEvent.stepStart({ index: 0 }),
...events, ...events,
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }), LLMEvent.stepFinish({
LLMEvent.finish({ reason: options.reason }), index: 0,
reason: options.reason,
usage: options.usage,
providerMetadata: options.providerMetadata,
}),
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
] ]
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events) export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
+11 -10
View File
@@ -81,7 +81,7 @@ OpenAI.configure({
}).responses("gpt-4.1-mini") }).responses("gpt-4.1-mini")
OpenAI.configure({ OpenAI.configure({
generation: { maxTokens: 100 }, generation: { maxTokens: 100 },
providerOptions: { openai: { store: false } }, providerOptions: { store: false },
}).responses("gpt-4.1-mini") }).responses("gpt-4.1-mini")
// @ts-expect-error OpenAI model selectors only accept model ids. // @ts-expect-error OpenAI model selectors only accept model ids.
@@ -97,7 +97,7 @@ OpenAI.configure({ bogus: true })
OpenAI.configure({ generation: { maxTokens: "many" } }) OpenAI.configure({ generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed. // @ts-expect-error provider-native options remain typed.
OpenAI.configure({ providerOptions: { openai: { store: "false" } } }) OpenAI.configure({ providerOptions: { store: "false" } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") }) OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
@@ -139,7 +139,8 @@ Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
Anthropic.configure({ Anthropic.configure({
apiKey: "anthropic-key", apiKey: "anthropic-key",
providerOptions: { providerOptions: {
anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 }, effort: "high" }, thinking: { type: "enabled", budgetTokens: 1_024 },
effort: "high",
}, },
}).model("claude-haiku") }).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids. // @ts-expect-error Anthropic model selectors only accept model ids.
@@ -147,15 +148,15 @@ Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source. // @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" }) Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
// @ts-expect-error Enabled Anthropic thinking requires a token budget. // @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled" } } } }) Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
// @ts-expect-error Anthropic thinking budgets must be numbers. // @ts-expect-error Anthropic thinking budgets must be numbers.
Anthropic.configure({ providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: "large" } } } }) Anthropic.configure({ providerOptions: { thinking: { type: "enabled", budgetTokens: "large" } } })
AnthropicCompatible.configure({ AnthropicCompatible.configure({
apiKey: "messages-key", apiKey: "messages-key",
baseURL: "https://messages.example.com/v1", baseURL: "https://messages.example.com/v1",
provider: "example", provider: "example",
providerOptions: { anthropic: { thinking: { type: "disabled" } } }, providerOptions: { thinking: { type: "disabled" } },
}).model("compatible-model") }).model("compatible-model")
// @ts-expect-error Anthropic-compatible providers require a base URL. // @ts-expect-error Anthropic-compatible providers require a base URL.
AnthropicCompatible.configure({ apiKey: "messages-key" }) AnthropicCompatible.configure({ apiKey: "messages-key" })
@@ -171,16 +172,16 @@ AnthropicCompatible.model("compatible-model", {
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
Google.configure({ Google.configure({
apiKey: "google-key", apiKey: "google-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } }, providerOptions: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } },
}).model("gemini-2.5-flash") }).model("gemini-2.5-flash")
// @ts-expect-error Google model selectors only accept model ids. // @ts-expect-error Google model selectors only accept model ids.
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {}) Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
// @ts-expect-error Gemini thinking budgets must be numbers. // @ts-expect-error Gemini thinking budgets must be numbers.
Google.configure({ providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } } }) Google.configure({ providerOptions: { thinkingConfig: { thinkingBudget: "large" } } })
GoogleVertex.configure({ GoogleVertex.configure({
apiKey: "vertex-key", apiKey: "vertex-key",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
}).model("gemini-3.5-flash") }).model("gemini-3.5-flash")
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash") GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash") GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
@@ -230,7 +231,7 @@ GoogleVertexResponses.configure({
GoogleVertexMessages.configure({ GoogleVertexMessages.configure({
accessToken: "vertex-token", accessToken: "vertex-token",
project: "project", project: "project",
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" } }, providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
}).model("claude-sonnet-4-6") }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys. // @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
+21 -28
View File
@@ -17,31 +17,25 @@ describe("request option precedence", () => {
test("deep-merges provider option records and replaces arrays, primitives, and null", () => { test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions( const merged = mergeProviderOptions(
{ {
openai: { include: ["route"],
include: ["route"], metadata: { route: true, shared: "route" },
metadata: { route: true, shared: "route" }, nullable: "route",
nullable: "route", primitive: "route",
primitive: "route",
},
}, },
{ {
openai: { include: ["model"],
include: ["model"], metadata: { model: true, shared: "model" },
metadata: { model: true, shared: "model" }, nullable: null,
nullable: null, primitive: "model",
primitive: "model",
},
}, },
{ openai: { metadata: { request: true }, primitive: false } }, { metadata: { request: true }, primitive: false },
) )
expect(merged).toEqual({ expect(merged).toEqual({
openai: { include: ["model"],
include: ["model"], metadata: { route: true, model: true, request: true, shared: "model" },
metadata: { route: true, model: true, request: true, shared: "model" }, nullable: null,
nullable: null, primitive: false,
primitive: false,
},
}) })
}) })
@@ -51,13 +45,13 @@ describe("request option precedence", () => {
endpoint: { baseURL: "https://api.openai.test/v1/" }, endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"), auth: Auth.bearer("test"),
generation: { maxTokens: 10, temperature: 1, stop: ["route"] }, generation: { maxTokens: 10, temperature: 1, stop: ["route"] },
providerOptions: { openai: { store: false, reasoningEffort: "low" } }, providerOptions: { store: false, reasoningEffort: "low" },
}) })
const model = route.model({ const model = route.model({
id: "gpt-4o-mini", id: "gpt-4o-mini",
defaults: { defaults: {
generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] }, generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] },
providerOptions: { openai: { reasoningEffort: "medium" } }, providerOptions: { reasoningEffort: "medium" },
}, },
}) })
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -65,7 +59,7 @@ describe("request option precedence", () => {
model, model,
prompt: "Say hello.", prompt: "Say hello.",
generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, generation: { maxTokens: 30, topP: 0.9, stop: ["request"] },
providerOptions: { openai: { store: true } }, providerOptions: { store: true },
}), }),
) )
@@ -276,21 +270,20 @@ describe("request option precedence", () => {
), ),
) )
it.effect("uses model output limits after route limits and before call maxTokens", () => it.effect("uses the Anthropic default before call maxTokens", () =>
Effect.gen(function* () { Effect.gen(function* () {
const route = AnthropicMessages.route.with({ const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" }, endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"), auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
}) })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) const model = route.model({ id: "claude-sonnet-4-5" })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest( const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
) )
expect(withoutMaxTokens.body.max_tokens).toBe(64) expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
expect(withMaxTokens.body.max_tokens).toBe(32) expect(withMaxTokens.body.max_tokens).toBe(8_000)
}), }),
) )
}) })
+1 -1
View File
@@ -105,7 +105,7 @@ export function continuationRequest(input: {
tools: features.has("tool-call") ? [continuationTool] : [], tools: features.has("tool-call") ? [continuationTool] : [],
cache: "none", cache: "none",
providerOptions: features.has("encrypted-reasoning") providerOptions: features.has("encrypted-reasoning")
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } } ? { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" }
: undefined, : undefined,
generation: { maxTokens: 80, temperature: 0 }, generation: { maxTokens: 80, temperature: 0 },
}) })
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
+6 -8
View File
@@ -13,9 +13,7 @@ interface ExampleOptions {
readonly mode?: "fast" | "thorough" readonly mode?: "fast" | "thorough"
} }
type ExampleProviderOptions = ProviderOptions & { type ExampleProviderOptions = ProviderOptions & ExampleOptions
readonly example?: ExampleOptions
}
const model = OpenAIChat.route const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } }) .with({ endpoint: { baseURL: "https://example.com/v1" } })
@@ -26,7 +24,7 @@ type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R
type Equal<A, B> = [A, B] extends [B, A] ? true : false type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T type Assert<T extends true> = T
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" })) const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
@@ -38,14 +36,14 @@ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Known provider options preserve their value types. // @ts-expect-error Known provider options preserve their value types.
providerOptions: { example: { mode: "slow" } }, providerOptions: { mode: "slow" },
}) })
const generatedObject = LLM.generateObject({ const generatedObject = LLM.generateObject({
model, model,
prompt: "Hello", prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }), schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } }, providerOptions: { mode: "thorough" },
}) })
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>> type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
@@ -61,13 +59,13 @@ LLM.generateObject({
prompt: "Hello", prompt: "Hello",
jsonSchema: { type: "object" }, jsonSchema: { type: "object" },
// @ts-expect-error Dynamic object generation uses the selected model's provider options. // @ts-expect-error Dynamic object generation uses the selected model's provider options.
providerOptions: { example: { mode: false } }, providerOptions: { mode: false },
}) })
declare const generic: LanguageModel declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } } const options: LanguageModelProviderOptions<typeof model> = { mode: "fast" }
void (options satisfies LanguageModelProviderOptions<typeof model>) void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements) void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements) void (true satisfies StreamClientRequirements)
+5 -7
View File
@@ -59,18 +59,18 @@ describe("llm constructors", () => {
provider: "fake", provider: "fake",
route: chatRoute.with({ route: chatRoute.with({
generation: { maxTokens: 100, temperature: 1 }, generation: { maxTokens: 100, temperature: 1 },
providerOptions: { openai: { store: false, metadata: { model: true } } }, providerOptions: { store: false, metadata: { model: true } },
http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } }, http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
}), }),
}), }),
prompt: "Say hello.", prompt: "Say hello.",
generation: { temperature: 0 }, generation: { temperature: 0 },
providerOptions: { openai: { store: true, metadata: { request: true } } }, providerOptions: { store: true, metadata: { request: true } },
http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } }, http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } },
}) })
expect(request.generation).toEqual({ temperature: 0 }) expect(request.generation).toEqual({ temperature: 0 })
expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } }) expect(request.providerOptions).toEqual({ store: true, metadata: { request: true } })
expect(request.http).toEqual({ expect(request.http).toEqual({
body: { metadata: { request: true } }, body: { metadata: { request: true } },
headers: { "x-shared": "request" }, headers: { "x-shared": "request" },
@@ -121,18 +121,16 @@ describe("llm constructors", () => {
const model = chatRoute.model({ const model = chatRoute.model({
id: "kimi-k2", id: "kimi-k2",
defaults: { defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] }, generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { openai: { parallelToolCalls: false } }, providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } }, http: { body: { extra_body: true } },
}, },
compatibility: { toolSchema: "moonshot" }, compatibility: { toolSchema: "moonshot" },
}) })
const request = LLM.request({ model, prompt: "Say hello." }) const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" })
expect(request.generation).toBeUndefined() expect(request.generation).toBeUndefined()
@@ -3,11 +3,11 @@ import { AnthropicCompatible } from "../../src/providers.js"
const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude") const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { effort: "high" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Anthropic effort must be a string. // @ts-expect-error Anthropic effort must be a string.
providerOptions: { anthropic: { effort: 1 } }, providerOptions: { effort: 1 },
}) })
@@ -3,11 +3,11 @@ import { Anthropic } from "../../src/providers.js"
const model = Anthropic.provider.model("claude-sonnet-4-5") const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } }) LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Anthropic thinking modes are a fixed union. // @ts-expect-error Anthropic thinking modes are a fixed union.
providerOptions: { anthropic: { thinking: { type: "automatic" } } }, providerOptions: { thinking: { type: "automatic" } },
}) })
@@ -3,11 +3,11 @@ import { Azure } from "../../src/providers.js"
const model = Azure.configure({ resourceName: "example" }).responses("deployment") const model = Azure.configure({ resourceName: "example" }).responses("deployment")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } }) LLM.request({ model, prompt: "Hello", providerOptions: { store: false } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Azure OpenAI store must be boolean. // @ts-expect-error Azure OpenAI store must be boolean.
providerOptions: { openai: { store: "false" } }, providerOptions: { store: "false" },
}) })
@@ -3,11 +3,11 @@ import { GoogleVertexChat } from "../../src/providers.js"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini") const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union. // @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { openai: { serviceTier: "premium" } }, providerOptions: { serviceTier: "premium" },
}) })
@@ -3,11 +3,11 @@ import { GoogleVertexMessages } from "../../src/providers.js"
const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude") const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude")
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { effort: "medium" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Vertex Anthropic effort must be a string. // @ts-expect-error Vertex Anthropic effort must be a string.
providerOptions: { anthropic: { effort: false } }, providerOptions: { effort: false },
}) })
@@ -3,11 +3,12 @@ import { GoogleVertexResponses } from "../../src/providers.js"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini") const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union. // @ts-expect-error Vertex Responses verbosity must be a string.
providerOptions: { openresponses: { textVerbosity: "verbose" } }, providerOptions: { textVerbosity: 1 },
}) })
@@ -6,12 +6,12 @@ const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } }, providerOptions: { thinkingConfig: { includeThoughts: true } },
}) })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Vertex Gemini includeThoughts must be boolean. // @ts-expect-error Vertex Gemini includeThoughts must be boolean.
providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } }, providerOptions: { thinkingConfig: { includeThoughts: "yes" } },
}) })
@@ -6,17 +6,15 @@ const model = Google.provider.model("gemini-2.5-pro")
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } }, providerOptions: { thinkingConfig: { thinkingBudget: 1024 } },
}) })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { providerOptions: {
gemini: { // @ts-expect-error Gemini safety settings require a threshold for every category.
// @ts-expect-error Gemini safety settings require a threshold for every category. safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
},
}, },
}) })
@@ -24,12 +22,10 @@ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { providerOptions: {
gemini: { cachedContent: "cachedContents/example",
cachedContent: "cachedContents/example", safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }], serviceTier: "future-tier",
serviceTier: "future-tier", thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
},
}, },
}) })
@@ -37,11 +33,11 @@ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Gemini thinking budgets must be numeric. // @ts-expect-error Gemini thinking budgets must be numeric.
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } }, providerOptions: { thinkingConfig: { thinkingBudget: "large" } },
}) })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } }, providerOptions: { thinkingConfig: { thinkingLevel: "maximum" } },
}) })
@@ -3,11 +3,15 @@ import { OpenAICompatibleResponses } from "../../src/providers.js"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model") const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Open Responses reasoning summaries use a fixed union. // @ts-expect-error Open Responses reasoning summaries use a fixed union.
providerOptions: { openresponses: { reasoningSummary: "full" } }, providerOptions: { reasoningSummary: "full" },
}) })
@@ -3,11 +3,11 @@ import { OpenAICompatible } from "../../src/providers.js"
const model = OpenAICompatible.deepseek.model("deepseek-chat") const model = OpenAICompatible.deepseek.model("deepseek-chat")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } }) LLM.request({ model, prompt: "Hello", providerOptions: { store: false } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error OpenAI-compatible store must be boolean. // @ts-expect-error OpenAI-compatible store must be boolean.
providerOptions: { openai: { store: "false" } }, providerOptions: { store: "false" },
}) })
@@ -2,14 +2,20 @@ import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js" import { OpenAI } from "../../src/providers.js"
const selected = OpenAI.responses("gpt-5") const selected = OpenAI.responses("gpt-5")
const chat = OpenAI.chat("gpt-4o-mini")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ LLM.request({
model: selected, model: selected,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string. // @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } }, providerOptions: { reasoningEffort: 1 },
}) })
OpenAI.configure({ OpenAI.configure({
@@ -3,27 +3,25 @@ import { OpenRouter } from "../../src/providers.js"
const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5") const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } }) LLM.request({ model, prompt: "Hello", providerOptions: { usage: true } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
providerOptions: { providerOptions: {
openrouter: { models: ["google/gemini-3.1-pro"],
models: ["google/gemini-3.1-pro"], provider: {
provider: { order: ["anthropic"],
order: ["anthropic"], require_parameters: true,
require_parameters: true, data_collection: "future-policy",
data_collection: "future-policy", sort: "future-sort",
sort: "future-sort", max_price: { prompt: "0.50" },
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
}, },
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
}, },
}) })
@@ -31,5 +29,5 @@ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error OpenRouter usage must be boolean or an option record. // @ts-expect-error OpenRouter usage must be boolean or an option record.
providerOptions: { openrouter: { usage: "yes" } }, providerOptions: { usage: "yes" },
}) })
@@ -3,11 +3,12 @@ import { XAI } from "../../src/providers.js"
const model = XAI.provider.model("grok-4") const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string. // @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
providerOptions: { xai: { reasoningEffort: true } }, providerOptions: { reasoningEffort: true },
}) })
+13 -33
View File
@@ -43,25 +43,23 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1", baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
} }
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", { const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings, ...settings,
providerOptions: { openrouter: { usage: true } }, providerOptions: { usage: true },
}) })
const xai = XAI.model("grok-4", { const xai = XAI.model("grok-4", {
...settings, ...settings,
providerOptions: { xai: { reasoningEffort: "high" } }, providerOptions: { reasoningEffort: "high" },
}) })
for (const selected of [openrouter, xai]) { for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL) expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers) expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body) expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
} }
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } }) expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } }) expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
}) })
test("maps package settings onto the executable model", () => { test("maps package settings onto the executable model", () => {
@@ -70,14 +68,12 @@ describe("provider package entrypoints", () => {
baseURL: "https://api.openai.test/v1", baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
unrelatedInheritedSetting: true, unrelatedInheritedSetting: true,
}) })
expect(selected.route.id).toBe("openai-responses") expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
}) })
test("maps OpenAI-compatible Responses settings onto the executable model", async () => { test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
@@ -88,8 +84,7 @@ describe("provider package entrypoints", () => {
provider: "example", provider: "example",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 }, providerOptions: { reasoningEffort: "low", store: true },
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
}) })
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
@@ -100,10 +95,7 @@ describe("provider package entrypoints", () => {
}) })
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
expect(selected.route.defaults.providerOptions).toEqual({
openresponses: { reasoningEffort: "low", store: true },
})
}) })
test("maps Anthropic-compatible settings onto the executable model", async () => { test("maps Anthropic-compatible settings onto the executable model", async () => {
@@ -114,8 +106,7 @@ describe("provider package entrypoints", () => {
provider: "example", provider: "example",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } }, body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 }, providerOptions: { effort: "low" },
providerOptions: { anthropic: { effort: "low" } },
}) })
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
@@ -126,20 +117,17 @@ describe("provider package entrypoints", () => {
}) })
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } }) expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
expect(selected.route.defaults.providerOptions).toEqual({ anthropic: { effort: "low" } })
}) })
test("maps Anthropic provider options onto the executable model", async () => { test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic") const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", { const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture", apiKey: "fixture",
providerOptions: { anthropic: { thinking: { type: "adaptive" } } }, providerOptions: { thinking: { type: "adaptive" } },
}) })
expect(selected.route.defaults.providerOptions).toEqual({ expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
anthropic: { thinking: { type: "adaptive" } },
})
}) })
test("requires an Anthropic-compatible base URL at runtime", async () => { test("requires an Anthropic-compatible base URL at runtime", async () => {
@@ -189,7 +177,6 @@ describe("provider package entrypoints", () => {
resourceName: "opencode-test", resourceName: "opencode-test",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
} }
const responses = AzureResponses.model("deployment", settings) const responses = AzureResponses.model("deployment", settings)
@@ -200,7 +187,6 @@ describe("provider package entrypoints", () => {
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat") expect(chat.route.id).toBe("azure-openai-chat")
}) })
@@ -232,18 +218,14 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta", baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { safetySettings: [] }, body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 }, providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
}) })
expect(selected.route.id).toBe("gemini") expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
expect(selected.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
}) })
test("selects Vertex entrypoints with the same model contract", async () => { test("selects Vertex entrypoints with the same model contract", async () => {
@@ -256,7 +238,6 @@ describe("provider package entrypoints", () => {
apiKey: "fixture", apiKey: "fixture",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { safetySettings: [] }, body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
}) })
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", { const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture", accessToken: "fixture",
@@ -280,7 +261,6 @@ describe("provider package entrypoints", () => {
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google") expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect( expect(
GoogleVertex.model("gemini-3.5-flash", { GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture", accessToken: "fixture",
@@ -305,7 +285,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi", baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses", path: "/responses",
}) })
expect(responses.route.defaults.providerOptions).toEqual({ openresponses: { store: false } }) expect(responses.route.defaults.providerOptions).toEqual({ store: false })
}) })
test("rejects conflicting Vertex auth settings at runtime", async () => { test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -63,7 +63,8 @@ describe("Anthropic Messages route", () => {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}, },
}), }),
) )
@@ -79,17 +80,17 @@ describe("Anthropic Messages route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const enabled = yield* compileRequest( const enabled = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } }, providerOptions: { thinking: { type: "enabled", budgetTokens: 1_024 } },
}), }),
) )
const legacy = yield* compileRequest( const legacy = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } }, providerOptions: { thinking: { type: "enabled", budget_tokens: 2_048 } },
}), }),
) )
const disabled = yield* compileRequest( const disabled = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "disabled" } } }, providerOptions: { thinking: { type: "disabled" } },
}), }),
) )
@@ -103,7 +104,7 @@ describe("Anthropic Messages route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { anthropic: { thinking: { type: "enabled" } } }, providerOptions: { thinking: { type: "enabled" } },
}), }),
).pipe(Effect.flip) ).pipe(Effect.flip)
@@ -321,7 +322,7 @@ describe("Anthropic Messages route", () => {
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
], ],
stream: true, stream: true,
max_tokens: 4096, max_tokens: 32_000,
}) })
}), }),
) )
@@ -1062,9 +1063,7 @@ describe("Anthropic Messages route", () => {
), ),
) )
expect(response.toolCalls).toMatchObject([ expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" }) expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}), }),
) )
+49 -9
View File
@@ -48,28 +48,26 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { providerOptions: {
gemini: { cachedContent: "cachedContents/example",
cachedContent: "cachedContents/example", safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }], serviceTier: "priority",
serviceTier: "priority", thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
},
}, },
}), }),
) )
const filtered = yield* compileRequest( const filtered = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } }, providerOptions: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } },
}), }),
) )
const defaulted = yield* compileRequest( const defaulted = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } }, providerOptions: { thinkingConfig: { thinkingLevel: "high" } },
}), }),
) )
const emptySafetySettings = yield* compileRequest( const emptySafetySettings = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
providerOptions: { gemini: { safetySettings: [] } }, providerOptions: { safetySettings: [] },
}), }),
) )
@@ -141,6 +139,48 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () => it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -62,7 +62,7 @@ describe("Google Vertex providers", () => {
accessToken: "vertex-token", accessToken: "vertex-token",
project: "vertex-project", project: "vertex-project",
providerOptions: { providerOptions: {
gemini: { labels: { component: "opencode", environment: "test" } }, labels: { component: "opencode", environment: "test" },
}, },
}).model("gemini-3.5-flash"), }).model("gemini-3.5-flash"),
prompt: "Say hello.", prompt: "Say hello.",
@@ -15,7 +15,7 @@ const cases = [
model: LanguageModel.update( model: LanguageModel.update(
OpenRouter.configure({ OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, providerOptions: { reasoning: { max_tokens: 1024 } },
}).model("anthropic/claude-sonnet-4.6"), }).model("anthropic/claude-sonnet-4.6"),
{ compatibility: { reasoningField: "reasoning" } }, { compatibility: { reasoningField: "reasoning" } },
), ),
+71 -3
View File
@@ -165,7 +165,7 @@ describe("OpenAI Chat route", () => {
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think", prompt: "think",
providerOptions: { openai: { reasoningEffort: "max" } }, providerOptions: { reasoningEffort: "max" },
}), }),
) )
@@ -221,7 +221,7 @@ describe("OpenAI Chat route", () => {
LLM.request({ LLM.request({
model, model,
prompt: "think", prompt: "think",
providerOptions: { openai: { reasoningEffort: "experimental" } }, providerOptions: { reasoningEffort: "experimental" },
}), }),
) )
@@ -255,7 +255,7 @@ describe("OpenAI Chat route", () => {
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).chat("gpt-4o-mini"), }).chat("gpt-4o-mini"),
@@ -664,6 +664,74 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () => it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js" import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js" import { configure } from "../../src/providers/openai-compatible-responses.js"
import { OpenAI } from "../../src/providers.js" import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js" import { OpenResponses } from "../../src/protocols/open-responses.js"
@@ -56,6 +56,28 @@ describe("Open Responses-compatible route", () => {
}), }),
) )
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("rejects OpenAI-native tools", () => it.effect("rejects OpenAI-native tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = configure({ const model = configure({
@@ -91,23 +113,111 @@ describe("Open Responses-compatible route", () => {
) )
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }], input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
}) })
}), }),
) )
it.effect("reads standard options from the Open Responses namespace", () => it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = configure({ const model = configure({
apiKey: "test-key", apiKey: "test-key",
baseURL: "https://responses.example.test/v1", baseURL: "https://responses.example.test/v1",
providerOptions: { openresponses: { reasoningEffort: "low", store: true } }, provider: "example",
}).model("example-model") }).model("example-model")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." })) const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
}),
)
it.effect("reads standard Open Responses options", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
providerOptions: {
reasoningEffort: "low",
store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
parallelToolCalls: false,
},
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
reasoning: { effort: "low" }, reasoning: { effort: "low" },
store: true, store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
mode: "auto",
tools: [{ type: "function", name: "lookup" }],
},
max_tool_calls: 2,
parallel_tool_calls: false,
}) })
}), }),
) )
@@ -159,8 +159,8 @@ describe("OpenAI Responses route", () => {
it.effect("lowers semantic service tier options", () => it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } }) const input = LLMRequest.update(request, { providerOptions: { serviceTier: "priority" } })
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) expect(input.providerOptions).toEqual({ serviceTier: "priority" })
const prepared = yield* compileRequest(input) const prepared = yield* compileRequest(input)
expect(prepared.body).toMatchObject({ service_tier: "priority" }) expect(prepared.body).toMatchObject({ service_tier: "priority" })
@@ -171,17 +171,27 @@ describe("OpenAI Responses route", () => {
it.effect("passes through custom OpenAI reasoning effort strings", () => it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), LLMRequest.update(request, { providerOptions: { reasoningEffort: "experimental" } }),
) )
expect(prepared.body.reasoning).toEqual({ effort: "experimental" }) expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
}), }),
) )
it.effect("passes through custom OpenAI text verbosity strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { textVerbosity: "verbose" } }),
)
expect(prepared.body.text).toEqual({ verbosity: "verbose" })
}),
)
it.effect("omits unsupported semantic service tiers", () => it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
) )
expect(prepared.body).not.toHaveProperty("service_tier") expect(prepared.body).not.toHaveProperty("service_tier")
@@ -241,28 +251,19 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("lowers chronological system updates to escaped user wrappers in order", () => it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model,
messages: [ messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
Message.user("Before."),
Message.system("Treat </system-update> literally."),
Message.assistant("After."),
],
}), }),
) )
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ { role: "user", content: [{ type: "input_text", text: "Before." }] },
role: "user", { role: "developer", content: "Operator update." },
content: [ { type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "input_text", text: "Before." },
{ type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
]) ])
}), }),
) )
@@ -484,7 +485,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("continues store-false reasoning without replaying the output-only item ID", () => it.effect("continues store-false reasoning while retaining the output item ID", () =>
Effect.gen(function* () { Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }] const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput } const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
@@ -514,6 +515,7 @@ describe("OpenAI Responses route", () => {
...firstInput, ...firstInput,
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }], summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted", encrypted_content: "encrypted",
}, },
@@ -1283,12 +1285,24 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
promptCacheKey: "session_123", promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
],
toolChoice: "none",
providerOptions: { providerOptions: {
openai: { reasoningEffort: "high",
reasoningEffort: "high", reasoningSummary: "auto",
reasoningSummary: "auto", include: ["reasoning.encrypted_content"],
include: ["reasoning.encrypted_content"], metadata: { environment: "test", tenant: "acme" },
}, safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
parallelToolCalls: false,
}, },
}), }),
) )
@@ -1298,6 +1312,23 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" }) expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "read" },
{ type: "function", name: "grep" },
],
})
expect(prepared.body.max_tool_calls).toBe(4)
expect(prepared.body.parallel_tool_calls).toBe(false)
}), }),
) )
@@ -1308,9 +1339,7 @@ describe("OpenAI Responses route", () => {
model, model,
prompt: "hi", prompt: "hi",
providerOptions: { providerOptions: {
openai: { include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
},
}, },
}), }),
) )
@@ -1323,48 +1352,41 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("filters unknown includable values out of the include array", () => it.effect("passes forward-compatible includable values through", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model,
prompt: "hi", prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the providerOptions: { include: ["reasoning.encrypted_content", "bogus.thing"] },
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}), }),
) )
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
}), }),
) )
it.effect("treats an explicit empty include as no include at all", () => it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { include: [] } }))
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
expect(prepared.body.include).toBeUndefined() expect(prepared.body.include).toBeUndefined()
}), }),
) )
it.effect("treats an all-invalid include as no include at all", () => it.effect("passes an unknown includable value through", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), LLM.request({ model, prompt: "hi", providerOptions: { include: ["bogus.thing"] } }),
) )
expect(prepared.body.include).toBeUndefined() expect(prepared.body.include).toEqual(["bogus.thing"])
}), }),
) )
it.effect("omits include when no include is set", () => it.effect("omits include when no include is set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { store: false } }))
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
expect(prepared.body.include).toBeUndefined() expect(prepared.body.include).toBeUndefined()
}), }),
@@ -1395,7 +1417,7 @@ describe("OpenAI Responses route", () => {
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi", prompt: "hi",
providerOptions: { openai: { include: [] } }, providerOptions: { include: [] },
}), }),
) )
@@ -1463,7 +1485,7 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" }, { type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
@@ -1484,6 +1506,108 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects malformed refusal events", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () => it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -1522,33 +1646,39 @@ describe("OpenAI Responses route", () => {
{ {
type: "text", type: "text",
text: "Checking.", text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } }, providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}, },
{ {
type: "text", type: "text",
text: "Finished.", text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } }, providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
}, },
{ {
type: "text", type: "text",
text: "Unclassified.", text: "Unclassified.",
providerMetadata: { openai: { phase: null } }, providerMetadata: { openai: { itemId: "msg_null", phase: null } },
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "message",
id: "msg_commentary",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Checking." }], content: [{ type: "output_text", text: "Checking." }],
phase: "commentary", phase: "commentary",
}, },
{ {
type: "message",
id: "msg_final",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Finished." }], content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer", phase: "final_answer",
}, },
{ {
type: "message",
id: "msg_null",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }], content: [{ type: "output_text", text: "Unclassified." }],
phase: null, phase: null,
@@ -1642,12 +1772,12 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" }, { type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2" }, { type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" }, { type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" }, { type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
]) ])
}), }),
) )
@@ -1679,7 +1809,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
]) ])
}), }),
) )
@@ -1719,7 +1849,7 @@ describe("OpenAI Responses route", () => {
it.effect("streams each reasoning summary part as a separate block", () => it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }), LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
@@ -1773,9 +1903,7 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () => it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
sseEvents( sseEvents(
@@ -1829,7 +1957,7 @@ describe("OpenAI Responses route", () => {
]), ]),
Message.user("Summarize it."), Message.user("Summarize it."),
], ],
providerOptions: { openai: { store: false } }, providerOptions: { store: false },
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -1842,6 +1970,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] }, { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }], summary: [{ type: "summary_text", text: "Checked the previous diff." }],
}, },
@@ -1849,7 +1978,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
], ],
}) })
expect(body.input[1]).not.toHaveProperty("id")
return input.respond( return input.respond(
sseEvents( sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1888,18 +2016,19 @@ describe("OpenAI Responses route", () => {
{ type: "text", text: "After." }, { type: "text", text: "After." },
]), ]),
], ],
providerOptions: { openai: { store: false } }, providerOptions: { store: false },
}), }),
) )
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }], summary: [{ type: "summary_text", text: "Checked order." }],
}, },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
]) ])
}), }),
) )
@@ -1918,7 +2047,7 @@ describe("OpenAI Responses route", () => {
}, },
]), ]),
], ],
providerOptions: { openai: { store: true } }, providerOptions: { store: true },
}), }),
) )
@@ -1951,7 +2080,7 @@ describe("OpenAI Responses route", () => {
]), ]),
Message.user("Continue."), Message.user("Continue."),
], ],
providerOptions: { openai: { store: true } }, providerOptions: { store: true },
}), }),
) )
@@ -2024,13 +2153,14 @@ describe("OpenAI Responses route", () => {
}, },
]), ]),
], ],
providerOptions: { openai: { store: false } }, providerOptions: { store: false },
}), }),
) )
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [ summary: [
{ type: "summary_text", text: "First" }, { type: "summary_text", text: "First" },
@@ -2064,7 +2194,7 @@ describe("OpenAI Responses route", () => {
]), ]),
Message.user("Summarize it."), Message.user("Summarize it."),
], ],
providerOptions: { openai: { store: false } }, providerOptions: { store: false },
}), }),
) )
@@ -2163,6 +2293,50 @@ describe("OpenAI Responses route", () => {
usage, usage,
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
[
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
expect(response.finishReason.normalized).toBe("tool-calls")
}), }),
) )
@@ -2229,6 +2403,35 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("retains function call item metadata when output_item.added is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () { Effect.gen(function* () {
const item = { const item = {
+11 -13
View File
@@ -141,7 +141,7 @@ describe("OpenRouter", () => {
LLM.request({ LLM.request({
model: OpenRouter.configure({ model: OpenRouter.configure({
apiKey: "test-key", apiKey: "test-key",
providerOptions: { openrouter: { usage: false } }, providerOptions: { usage: false },
}).model("openai/gpt-4o-mini"), }).model("openai/gpt-4o-mini"),
cache: "none", cache: "none",
prompt: "Hello", prompt: "Hello",
@@ -159,17 +159,15 @@ describe("OpenRouter", () => {
model: OpenRouter.configure({ model: OpenRouter.configure({
apiKey: "test-key", apiKey: "test-key",
providerOptions: { providerOptions: {
openrouter: { usage: true,
usage: true, reasoning: { effort: "high" },
reasoning: { effort: "high" }, models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"], provider: { order: ["anthropic", "google"], require_parameters: true },
provider: { order: ["anthropic", "google"], require_parameters: true }, plugins: [{ id: "response-healing" }],
plugins: [{ id: "response-healing" }], web_search_options: { engine: "native", max_results: 3 },
web_search_options: { engine: "native", max_results: 3 }, debug: { echo_upstream_body: true },
debug: { echo_upstream_body: true }, user: "user_123",
user: "user_123", future_option: { enabled: true },
future_option: { enabled: true },
},
}, },
}).model("anthropic/claude-3.7-sonnet:thinking"), }).model("anthropic/claude-3.7-sonnet:thinking"),
prompt: "Think briefly.", prompt: "Think briefly.",
@@ -210,7 +208,7 @@ describe("OpenRouter", () => {
LLM.request({ LLM.request({
model: OpenRouter.configure({ model: OpenRouter.configure({
apiKey: "test-key", apiKey: "test-key",
providerOptions: { openrouter: invalid }, providerOptions: invalid,
}).model("openai/gpt-4o-mini"), }).model("openai/gpt-4o-mini"),
prompt: "Hello", prompt: "Hello",
}), }),
+6 -9
View File
@@ -181,12 +181,10 @@ const normalizeImageText = (value: string) =>
.trim() .trim()
const encryptedReasoningOptions = { const encryptedReasoningOptions = {
openai: { store: false,
store: false, include: ["reasoning.encrypted_content"],
include: ["reasoning.encrypted_content"], reasoningEffort: "low",
reasoningEffort: "low", reasoningSummary: "auto",
reasoningSummary: "auto",
},
} as const } as const
type AssistantTextExpectation = string | RegExp type AssistantTextExpectation = string | RegExp
@@ -304,8 +302,7 @@ const runTextScenario = (context: GoldenScenarioContext) =>
assistant.expectText(/^Hello!?$/, { assistant.expectText(/^Hello!?$/, {
system: "You are concise.", system: "You are concise.",
maxTokens: context.maxTokens ?? 40, maxTokens: context.maxTokens ?? 40,
providerOptions: providerOptions: context.model.route.id === "gemini" ? { thinkingConfig: { thinkingBudget: 0 } } : undefined,
context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
}), }),
]) ])
@@ -388,7 +385,7 @@ const runReasoningScenario = (context: GoldenScenarioContext) =>
user("Think briefly, then reply exactly with: Hello!"), user("Think briefly, then reply exactly with: Hello!"),
assistant.expectText(/^Hello!?$/, { assistant.expectText(/^Hello!?$/, {
system: "Show concise reasoning when the provider supports visible reasoning summaries.", system: "Show concise reasoning when the provider supports visible reasoning summaries.",
providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } }, providerOptions: { reasoningEffort: "low", reasoningSummary: "auto" },
maxTokens: context.maxTokens ?? 120, maxTokens: context.maxTokens ?? 120,
assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0), assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0),
}), }),
+2 -2
View File
@@ -55,7 +55,7 @@ const schema_only_weather = Tool.make({
}) })
describe("LLMClient tools", () => { describe("LLMClient tools", () => {
it.effect("uses the registered model route when adding runtime tools", () => it.effect("uses the selected model route when adding runtime tools", () =>
Effect.gen(function* () { Effect.gen(function* () {
const layer = scriptedResponses([ const layer = scriptedResponses([
sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
@@ -636,7 +636,7 @@ describe("LLMClient tools", () => {
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-5.5" }), .model({ id: "gpt-5.5" }),
prompt: "Use the tool.", prompt: "Use the tool.",
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } }, providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}), }),
tools: { get_weather }, tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer)) }).pipe(Stream.runCollect, Effect.provide(layer))
+22
View File
@@ -2,12 +2,34 @@
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially. The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
The `devex` category is the explicit exception to the production-build rule. It measures development commands from submission through a user-visible ready state and has its own Playwright configuration.
Run the suite explicitly from `packages/app`: Run the suite explicitly from `packages/app`:
```sh ```sh
bun run test:bench bun run test:bench
``` ```
Run the desktop development startup benchmark from the repository root:
```sh
bun run bench:devex
```
It runs five serial samples of the exact `bun dev:desktop` command. Each sample uses a fresh desktop profile, database, service configuration, service registration, and service process; the desktop selects an isolated ephemeral loopback endpoint. It removes desktop build output and the desktop Vite cache before every run; dependencies, Bun's package cache, and Electron remain installed. The harness stops only that sample's service; it does not stop or change the elected global OpenCode service. The measured endpoint is a visible Home page whose empty-state controls pass Playwright actionability checks. The command's Electron installation check remains inside the measured interval.
Set `DESKTOP_STARTUP_RUNS` only for focused diagnostics:
```sh
DESKTOP_STARTUP_RUNS=1 bun run bench:devex
```
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to capture the renderer's CDP trace from attachment through actionable Home:
```sh
DESKTOP_STARTUP_RUNS=1 OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-desktop-traces bun run bench:devex
```
PowerShell: PowerShell:
```powershell ```powershell
+2 -2
View File
@@ -18,9 +18,9 @@ const categories = [
"disabled-by-default-v8.cpu_profiler", "disabled-by-default-v8.cpu_profiler",
] ]
export async function startChromeTrace(page: Page, name: string) { export async function startChromeTrace(page: Page, name: string): Promise<undefined | (() => Promise<string>)> {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return if (!directory) return undefined
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1" const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors) const file = await prepareChromeTrace(directory, name, selectors)
@@ -0,0 +1,26 @@
import { benchmark } from "../benchmark"
import {
desktopBenchmarkContext,
runDesktopStartup,
summarizeDesktopStartup,
type DesktopStartupSample,
} from "./desktop-startup"
benchmark.describe("devex: desktop startup", () => {
benchmark("opens a cold desktop on Home", async ({ report }, testInfo) => {
benchmark.setTimeout(15 * 60_000)
const runs = Number(process.env.DESKTOP_STARTUP_RUNS ?? 5)
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("DESKTOP_STARTUP_RUNS must be a positive integer")
const samples: DesktopStartupSample[] = []
const context = await desktopBenchmarkContext(runs)
for (let run = 1; run <= runs; run++) {
const sample = await runDesktopStartup(run, testInfo).catch((error) => {
report(samples.length ? { samples, summary: summarizeDesktopStartup(samples) } : { samples }, context)
throw error
})
samples.push(sample)
}
report({ samples, summary: summarizeDesktopStartup(samples) }, context)
})
})
@@ -0,0 +1,526 @@
import { Service } from "@opencode-ai/client/service"
import { chromium, expect, type Browser, type Page, type TestInfo } from "@playwright/test"
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { startChromeTrace } from "../chrome-trace"
const repository = resolve(import.meta.dirname, "../../../../..")
const milestones = [
"bunRootScript",
"bunDesktopScript",
"desktopPrepared",
"mainBundleReady",
"preloadBundleReady",
"rendererDevServerReady",
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
"desktopPreparation",
"viteMainBundle",
"vitePreloadBundle",
"rendererServerStartup",
"electronStartup",
"serviceSpawnWait",
"serviceProcessStartup",
"rendererStartup",
"visibleWindowToHome",
] as const
type Milestone = (typeof milestones)[number]
type Phase = (typeof phases)[number]
type ServiceInfo = { id: string; version: string; url: string; pid: number }
export type DesktopStartupSample = {
run: number
commandToHomeReadyMs: number
milestonesMs: Record<Milestone, number>
phasesMs: Record<Phase, number>
service: Omit<ServiceInfo, "id">
}
export async function runDesktopStartup(run: number, testInfo: TestInfo) {
const profile = await createColdProfile()
const desktop = await Promise.resolve()
.then(() => startDesktop(profile))
.catch(async (error) => {
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
try {
const page = await desktop.open()
const stopTrace = await startChromeTrace(page, `desktop-startup-${run}`)
try {
await startThemeObservation(page)
await waitForHome(page, desktop.mark)
await requireStableTheme(page)
return await desktop.result(run)
} finally {
await stopTrace?.()
}
} finally {
await desktop.close(testInfo, run)
}
}
export async function desktopBenchmarkContext(runs: number) {
const pkg = JSON.parse(await readFile(join(repository, "packages/desktop/package.json"), "utf8"))
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository })
if (revision.status !== 0) throw new Error("Failed to read the benchmark Git revision")
const status = spawnSync("git", ["status", "--porcelain"], { cwd: repository })
if (status.status !== 0) throw new Error("Failed to read the benchmark Git status")
const bun = spawnSync("bun", ["--version"], { cwd: repository })
if (bun.status !== 0) throw new Error("Failed to read the benchmark Bun version")
return {
arch: process.arch,
command: "bun dev:desktop",
runs,
profile: "fresh",
service: "isolated-cold",
install: "complete",
viteCache: "cold",
electronInstall: "present",
bunVersion: bun.stdout.toString().trim(),
electronVersion: pkg.devDependencies.electron,
electronViteVersionRange: pkg.devDependencies["electron-vite"],
gitCommit: revision.stdout.toString().trim(),
gitDirty: status.stdout.length > 0,
trace: Boolean(process.env.OPENCODE_PERFORMANCE_TRACE_DIR),
}
}
export function summarizeDesktopStartup(samples: DesktopStartupSample[]) {
return {
commandToHomeReadyMs: statistics(samples.map((sample) => sample.commandToHomeReadyMs)),
milestonesMs: Object.fromEntries(
milestones.map((name) => [name, statistics(samples.map((sample) => sample.milestonesMs[name]))]),
),
phasesMs: Object.fromEntries(
phases.map((name) => [name, statistics(samples.map((sample) => sample.phasesMs[name]))]),
),
}
}
export function milestoneForLine(line: string): Milestone | undefined {
const text = stripAnsi(line)
return milestonePatterns.find((item) => text.includes(item.text))?.name
}
const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "bunRootScript", text: "$ bun --cwd packages/desktop dev" },
{ name: "bunDesktopScript", text: "$ bun ./scripts/dev.ts" },
{ name: "desktopPrepared", text: "Copied dev icons from" },
{ name: "mainBundleReady", text: "electron main process built successfully" },
{ name: "preloadBundleReady", text: "electron preload scripts built successfully" },
{ name: "rendererDevServerReady", text: "dev server running for the electron renderer process at:" },
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
await Promise.all(
["packages/desktop/node_modules/.vite", "packages/desktop/out"].map((path) =>
rm(join(repository, path), { recursive: true, force: true }),
),
)
const root = await mkdtemp(join(tmpdir(), "opencode-desktop-startup-"))
return initializeColdProfile(root).catch(async (error) => {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
}
async function initializeColdProfile(root: string) {
await Promise.all(
["data", "config", "cache", "state", "desktop", "session", "home"].map((dir) =>
mkdir(join(root, dir), { recursive: true }),
),
)
await Promise.all([
writeFile(
join(root, "desktop", "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true }),
),
writeFile(join(root, "desktop", "opencode.global.dat"), JSON.stringify({ language: '{"locale":"en"}' })),
])
const registration = join(root, "desktop", "opencode", "service-local.json")
await Service.stop({ file: registration })
return { root, registration }
}
function startDesktop(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const started = performance.now()
const child = spawn("bun", ["dev:desktop"], {
cwd: repository,
detached: process.platform !== "win32",
env: {
...process.env,
OPENCODE_CONFIG_DIR: join(profile.root, "config"),
OPENCODE_DB: join(profile.root, "data", "opencode.db"),
OPENCODE_TEST_HOME: join(profile.root, "home"),
OPENCODE_TEST_ONBOARDING: "0",
OPENCODE_DESKTOP_TEST_ROOT: profile.root,
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT: "0",
OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION: "1",
},
stdio: ["ignore", "pipe", "pipe"],
})
if (!child.pid || !child.stdout || !child.stderr) throw new Error("Failed to start the desktop command")
const exited = childExit(child)
const observed: Partial<Record<Milestone, number>> = {}
const endpoint = Promise.withResolvers<string>()
const pageErrors: string[] = []
let browser: Browser | undefined
let service: ServiceInfo | undefined
const mark = (name: Milestone) => {
observed[name] ??= elapsed(started)
}
const record = (line: string) => {
const milestone = milestoneForLine(line)
if (milestone) mark(milestone)
const match = stripAnsi(line).match(/DevTools listening on (ws:\/\/\S+)/)
if (match?.[1]) endpoint.resolve(match[1])
}
const stdout = observeOutput(child.stdout, record)
const stderr = observeOutput(child.stderr, record)
return {
mark,
async open() {
const url = await Promise.race([
endpoint.promise,
exited.then((code) => {
throw new Error(`Desktop command exited with code ${code} before opening its debug endpoint`)
}),
sleep(120_000).then(() => {
throw new Error("Timed out waiting for the desktop debug endpoint")
}),
])
browser = await chromium.connectOverCDP(url, { timeout: 120_000 })
const context = browser.contexts()[0]
if (!context) throw new Error("Electron did not expose a browser context")
await expect.poll(() => context.pages().length, { timeout: 120_000 }).toBeGreaterThan(0)
const page = context.pages()[0]
if (!page) throw new Error("Electron did not expose a renderer page")
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message))
return page
},
async result(run: number): Promise<DesktopStartupSample> {
if (pageErrors.length) throw new Error(`Desktop renderer reported errors:\n\n${pageErrors.join("\n\n")}`)
service = await readService(profile)
const milestonesMs = requireMilestones(observed)
return {
run,
commandToHomeReadyMs: milestonesMs.homeReady,
milestonesMs,
phasesMs: calculatePhases(milestonesMs),
service: {
version: service.version,
url: service.url,
pid: service.pid,
},
}
},
async close(testInfo: TestInfo, run: number) {
const errors: unknown[] = []
await browser?.close().catch(() => undefined)
await stopProcessTree(child, exited).catch((error) => {
errors.push(error)
child.stdout?.destroy()
child.stderr?.destroy()
})
const [stdoutText, stderrText] = await Promise.all([stdout, stderr]).catch((error) => {
errors.push(error)
return ["", ""]
})
await Promise.all([
testInfo.attach(`desktop-startup-${run}-stdout`, { body: stdoutText, contentType: "text/plain" }),
testInfo.attach(`desktop-startup-${run}-stderr`, { body: stderrText, contentType: "text/plain" }),
pageErrors.length
? testInfo.attach(`desktop-startup-${run}-page-errors`, {
body: pageErrors.join("\n\n"),
contentType: "text/plain",
})
: Promise.resolve(),
]).catch((error) => errors.push(error))
await Service.stop({ file: profile.registration }).catch((error) => errors.push(error))
if (service && processAlive(service.pid))
errors.push(new Error(`Desktop service process ${service.pid} did not stop`))
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch((error) =>
errors.push(error),
)
if (errors.length) throw new AggregateError(errors, "Desktop benchmark cleanup failed")
},
}
}
async function waitForHome(page: Page, mark: (name: Milestone) => void) {
await expect.poll(() => page.evaluate(() => document.visibilityState), { timeout: 120_000 }).toBe("visible")
const projects = page.getByRole("complementary", { name: "Projects", exact: true })
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
const addProject = projects.locator('button[data-action="home-add-project-row"]')
await expect(projects).toBeVisible({ timeout: 120_000 })
await expect(sessions).toBeVisible()
await expect(search).toBeEditable()
await expect(sessions.getByText("Nothing here yet", { exact: true })).toBeVisible()
await expect(addProject).toHaveCount(1)
await addProject.click({ trial: true })
mark("homeReady")
}
type ThemeWindow = Window & {
__OPENCODE_THEME_STATES__?: string[]
__OPENCODE_THEME_OBSERVER__?: MutationObserver
}
async function startThemeObservation(page: Page) {
await page.addInitScript(installThemeObservation)
await page.evaluate(installThemeObservation)
}
async function requireStableTheme(page: Page) {
const states = await page.evaluate(() => {
const target = window as ThemeWindow
target.__OPENCODE_THEME_OBSERVER__?.disconnect()
return target.__OPENCODE_THEME_STATES__ ?? []
})
if (states.length !== 1) throw new Error(`Desktop theme changed during startup: ${states.join(" -> ")}`)
}
function installThemeObservation() {
const target = window as ThemeWindow
const observeRoot = () => {
const root = document.documentElement
if (!root) return false
const state = () => {
const theme = root.dataset.theme
const scheme = root.dataset.colorScheme
return theme && scheme ? `${theme}:${scheme}` : undefined
}
const initial = state()
target.__OPENCODE_THEME_STATES__ = initial ? [initial] : []
target.__OPENCODE_THEME_OBSERVER__ = new MutationObserver(() => {
const next = state()
if (!next) return
if (target.__OPENCODE_THEME_STATES__?.at(-1) !== next) target.__OPENCODE_THEME_STATES__?.push(next)
})
target.__OPENCODE_THEME_OBSERVER__.observe(root, {
attributes: true,
attributeFilter: ["data-theme", "data-color-scheme"],
})
return true
}
if (observeRoot()) return
const documentObserver = new MutationObserver(() => {
if (!observeRoot()) return
documentObserver.disconnect()
})
target.__OPENCODE_THEME_OBSERVER__ = documentObserver
documentObserver.observe(document, { childList: true })
}
async function observeOutput(stream: NodeJS.ReadableStream, record: (line: string) => void) {
const decoder = new TextDecoder()
const output: string[] = []
let pending = ""
for await (const chunk of stream) {
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
output.push(text)
pending += text
const lines = pending.split(/\r?\n/)
pending = lines.pop() ?? ""
lines.forEach(record)
}
const final = decoder.decode()
output.push(final)
pending += final
if (pending) record(pending)
return output.join("")
}
async function readService(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const value: unknown = JSON.parse(await readFile(profile.registration, "utf8"))
if (!isServiceInfo(value)) throw new Error("Desktop service registration is invalid")
const url = new URL(value.url)
const port = Number(url.port)
if (url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port <= 0)
throw new Error(`Desktop service used unexpected endpoint ${value.url}`)
if (!value.version.startsWith("2.0.0-local-"))
throw new Error(`Desktop service used unexpected version ${value.version}`)
return value
}
function isServiceInfo(value: unknown): value is ServiceInfo {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"version" in value &&
typeof value.version === "string" &&
"url" in value &&
typeof value.url === "string" &&
"pid" in value &&
typeof value.pid === "number"
)
}
function requireMilestones(observed: Partial<Record<Milestone, number>>) {
const get = (name: Milestone) => {
const value = observed[name]
if (value === undefined) throw new Error(`Desktop startup did not report milestone: ${name}`)
return round(value)
}
return {
bunRootScript: get("bunRootScript"),
bunDesktopScript: get("bunDesktopScript"),
desktopPrepared: get("desktopPrepared"),
mainBundleReady: get("mainBundleReady"),
preloadBundleReady: get("preloadBundleReady"),
rendererDevServerReady: get("rendererDevServerReady"),
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
function calculatePhases(value: Record<Milestone, number>): Record<Phase, number> {
return {
desktopPreparation: value.desktopPrepared,
viteMainBundle: round(value.mainBundleReady - value.desktopPrepared),
vitePreloadBundle: round(value.preloadBundleReady - value.mainBundleReady),
rendererServerStartup: round(value.rendererDevServerReady - value.preloadBundleReady),
electronStartup: round(value.electronStarted - value.electronSpawnStarted),
serviceSpawnWait: round(value.serviceSpawnRequested - value.serviceEnsureStarted),
serviceProcessStartup: round(value.serviceReady - value.serviceSpawnRequested),
rendererStartup: round(value.homeReady - value.rendererViteConnected),
visibleWindowToHome: round(value.homeReady - value.windowVisible),
}
}
function statistics(values: number[]) {
if (!values.length) throw new Error("Cannot summarize an empty benchmark")
const sorted = values.toSorted((left, right) => left - right)
const median = medianOf(sorted)
return {
min: round(sorted[0]),
median: round(median),
max: round(sorted.at(-1)!),
medianAbsoluteDeviation: round(medianOf(sorted.map((value) => Math.abs(value - median)).toSorted((a, b) => a - b))),
}
}
function medianOf(sorted: number[]) {
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2) return sorted[middle]
return (sorted[middle - 1] + sorted[middle]) / 2
}
async function stopProcessTree(child: ChildProcess, exited: Promise<number | null>) {
if (!child.pid) throw new Error("Desktop command has no process ID")
if (process.platform !== "win32") return stopProcessGroup(child.pid, exited)
if (child.exitCode !== null || (await exitsWithin(child, exited, 2_000))) return
const kill = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
})
await childExit(kill)
if (await exitsWithin(child, exited, 10_000)) return
if (!(await exitsWithin(child, exited, 5_000))) throw new Error(`Desktop command process ${child.pid} did not stop`)
}
async function stopProcessGroup(pid: number, exited: Promise<number | null>) {
await Promise.race([exited, sleep(2_000)])
if (!processGroupAlive(pid)) return
process.kill(-pid, "SIGTERM")
if (await processGroupStopsWithin(pid, 10_000)) return
process.kill(-pid, "SIGKILL")
if (!(await processGroupStopsWithin(pid, 5_000))) throw new Error(`Desktop command process group ${pid} did not stop`)
}
async function processGroupStopsWithin(pid: number, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (!processGroupAlive(pid)) return true
await sleep(50)
}
return !processGroupAlive(pid)
}
function processGroupAlive(pid: number) {
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
async function exitsWithin(child: ChildProcess, exited: Promise<number | null>, timeout: number) {
if (child.exitCode !== null) return true
const result = await Promise.race([exited.then(() => true), sleep(timeout).then(() => false)])
return result
}
function childExit(child: ChildProcess) {
return new Promise<number | null>((resolve, reject) => {
child.once("error", reject)
child.once("exit", (code) => resolve(code))
})
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
}
function processAlive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
}
function elapsed(started: number) {
return round(performance.now() - started)
}
function round(value: number) {
return Math.round(value * 100) / 100
}
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default defineConfig({
testDir: ".",
testMatch: "desktop-startup-benchmark.spec.ts",
outputDir: "../../test-results/performance-devex",
timeout: 15 * 60_000,
expect: {
timeout: 120_000,
},
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../../playwright-report/performance-devex", open: "never" }], ["line"]],
projects: [{ name: "desktop" }],
})
@@ -7,7 +7,7 @@ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(
export default { export default {
...config, ...config,
testDir: ".", testDir: ".",
testIgnore: "unit/**", testIgnore: ["unit/**", "devex/**"],
outputDir: "../test-results/performance", outputDir: "../test-results/performance",
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
@@ -179,7 +179,7 @@ test.describe("timeline adverse visual stability", () => {
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
shell(shellID, "completed", wideLines(15)), shell(shellID, "completed", wideLines(15)),
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }), toolPart(contextIDs[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."), textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
]), ]),
@@ -1,4 +1,5 @@
import { test } from "@playwright/test" import { test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -18,16 +19,20 @@ import {
} from "./fixture" } from "./fixture"
const profiles = [ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, {
name: "edit",
tool: "edit",
input: { path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
},
{ {
name: "multi patch", name: "multi patch",
tool: "apply_patch", tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, input: { patchText: "Update generated files" },
}, },
] as const ] as const
for (const profile of profiles) { for (const profile of profiles) {
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => { test(`stabilizes ${profile.name} streaming to completed`, async ({ page }, testInfo) => {
const partID = `prt_file_matrix_${profiles.indexOf(profile)}` const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}` const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
@@ -35,7 +40,7 @@ for (const profile of profiles) {
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(partID, profile.tool, "pending", profile.input), toolPart(partID, profile.tool, "streaming", profile.input),
textPart(followingID, `Following ${profile.name}`), textPart(followingID, `Following ${profile.name}`),
], ],
{ completed: false }, { completed: false },
@@ -89,34 +94,27 @@ function completedPart(partID: string, profile: (typeof profiles)[number]) {
if (profile.tool === "edit") { if (profile.tool === "edit") {
return toolPart(partID, profile.tool, "completed", profile.input, { return toolPart(partID, profile.tool, "completed", profile.input, {
metadata: { metadata: {
filediff: { files: [patchFile("src/edit.ts", "modified", 50)],
file: "src/edit.ts",
additions: 50,
deletions: 50,
before: source(50, false),
after: source(50, true),
},
}, },
}) })
} }
const files = [ const files = [
patchFile("src/a.ts", "update"), patchFile("src/a.ts", "modified", 20),
patchFile("src/b.ts", "add"), patchFile("src/b.ts", "added", 20),
patchFile("src/old.ts", "delete"), patchFile("src/old.ts", "deleted", 20),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
] ]
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } }) return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
} }
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { function patchFile(file: string, status: "added" | "modified" | "deleted", lines: number) {
const before = status === "added" ? "" : source(lines, false)
const after = status === "deleted" ? "" : source(lines, true)
return { return {
filePath, file,
relativePath: filePath, status,
type, patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: type === "delete" ? 0 : 20, additions: status === "deleted" ? 0 : lines,
deletions: type === "add" ? 0 : 20, deletions: status === "added" ? 0 : lines,
before: type === "add" ? undefined : source(20, false),
after: type === "delete" ? undefined : source(20, true),
} }
} }
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -20,13 +21,13 @@ import {
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => { test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
const patchID = "prt_incremental_01_patch" const patchID = "prt_incremental_01_patch"
const followingID = "prt_incremental_02_following" const followingID = "prt_incremental_02_following"
const first = patchFile("src/a.ts", "update") const first = patchFile("src/a.ts", "modified")
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage( assistantMessage(
[ [
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), toolPart(patchID, "patch", "running", { patchText: "Update files" }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"), textPart(followingID, "Following incremental patch"),
], ],
{ completed: false }, { completed: false },
@@ -55,15 +56,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
}, },
}) })
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "add") const second = patchFile("src/b.ts", "added")
const third = patchFile("src/old.ts", "delete") const third = patchFile("src/old.ts", "deleted")
await timeline.send( await timeline.send(
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"running", "running",
{ files: [first.filePath, second.filePath] }, { patchText: "Update files" },
{ metadata: { files: [first, second] } }, { metadata: { files: [first, second] } },
), ),
), ),
@@ -73,9 +74,9 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated( partUpdated(
toolPart( toolPart(
patchID, patchID,
"apply_patch", "patch",
"completed", "completed",
{ files: [first.filePath, second.filePath, third.filePath] }, { patchText: "Update files" },
{ metadata: { files: [first, second, third] } }, { metadata: { files: [first, second, third] } },
), ),
), ),
@@ -106,15 +107,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible() await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
}) })
function patchFile(filePath: string, type: "add" | "update" | "delete") { function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return { return {
filePath, file,
relativePath: filePath, status,
type, patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: type === "delete" ? 0 : 4, additions: status === "deleted" ? 0 : 4,
deletions: type === "add" ? 0 : 3, deletions: status === "added" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
} }
} }
@@ -35,7 +35,7 @@ describe("timeline fixture validation", () => {
userMessage(), userMessage(),
{ {
...assistantMessage(), ...assistantMessage(),
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }], content: [{ type: "tool", id: "call_invalid", name: "shell", state: { status: "completed" } }],
} as never, } as never,
]), ]),
).toThrow() ).toThrow()
@@ -60,12 +60,11 @@ if (false) {
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user"> const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
userMessage([userSeed]) userMessage([userSeed])
// @ts-expect-error Tool completion fields are not valid while pending. // @ts-expect-error Tool completion fields are not valid while streaming.
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" }) toolPart("prt_invalid_streaming", "shell", "streaming", {}, { output: "impossible" })
// @ts-expect-error Tool completion fields are not valid while running. toolPart("prt_valid_running", "shell", "running", {}, { output: "progressive output" })
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
// @ts-expect-error Tool error fields are not valid after completion. // @ts-expect-error Tool error fields are not valid after completion.
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" }) toolPart("prt_invalid_completed", "shell", "completed", {}, { error: "impossible" })
assistantMessage([ assistantMessage([
// @ts-expect-error Agent references belong to user messages, not assistant messages. // @ts-expect-error Agent references belong to user messages, not assistant messages.
@@ -60,17 +60,17 @@ type ReasoningSeed = {
type ToolSeed = { type ToolSeed = {
id: string id: string
type: "tool" type: "tool"
callID: string name: string
tool: string
messageID?: string messageID?: string
executed?: boolean executed?: boolean
providerState?: Record<string, unknown> providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown> providerResultState?: Record<string, unknown>
state: state:
| { status: "pending"; input: Record<string, unknown>; raw: string } | { status: "streaming"; input: Record<string, unknown>; raw: string }
| { | {
status: "running" status: "running"
input: Record<string, unknown> input: Record<string, unknown>
output?: string
title?: string title?: string
metadata: Record<string, unknown> metadata: Record<string, unknown>
time: { start: number } time: { start: number }
@@ -100,10 +100,10 @@ export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
? TextSeed | FileSeed | AgentSeed ? TextSeed | FileSeed | AgentSeed
: TextSeed | ReasoningSeed | ToolSeed : TextSeed | ReasoningSeed | ToolSeed
type ToolOptions<State extends ToolStatus> = State extends "pending" type ToolOptions<State extends ToolStatus> = State extends "streaming"
? { output?: never; title?: never; metadata?: never; error?: never } ? { output?: never; title?: never; metadata?: never; error?: never }
: State extends "running" : State extends "running"
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never } ? { title?: string; metadata?: Record<string, unknown>; output?: string; error?: never }
: State extends "error" : State extends "error"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never } ? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never } : { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
@@ -371,6 +371,15 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
startedParts.add(part.id) startedParts.add(part.id)
if (!started && !part.text)
return [
makeEvent("session.reasoning.started", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
}),
]
return [ return [
...(started ...(started
? [] ? []
@@ -542,9 +551,9 @@ export function reasoningPart(id: string, text: string): ReasoningSeed {
export function toolPart( export function toolPart(
id: string, id: string,
tool: string, tool: string,
state: "pending", state: "streaming",
input: Record<string, unknown>, input: Record<string, unknown>,
options?: ToolOptions<"pending">, options?: ToolOptions<"streaming">,
): ToolSeed ): ToolSeed
export function toolPart( export function toolPart(
id: string, id: string,
@@ -574,14 +583,15 @@ export function toolPart(
input: Record<string, unknown>, input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {}, options: ToolOptions<ToolStatus> = {},
): ToolSeed { ): ToolSeed {
const base = { id, type: "tool" as const, callID: id, tool } const base = { id, type: "tool" as const, name: tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "streaming") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running") if (state === "running")
return { return {
...base, ...base,
state: { state: {
status: state, status: state,
input, input,
...(options.output === undefined ? {} : { output: options.output }),
title: options.title, title: options.title,
metadata: options.metadata ?? {}, metadata: options.metadata ?? {},
time: { start: 1700000001000 }, time: { start: 1700000001000 },
@@ -612,12 +622,10 @@ export function toolPart(
} }
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed { export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
if (state === "pending") return toolPart(id, "bash", state, { command }) if (state === "streaming") return toolPart(id, "shell", state, { command })
if (state === "running") if (state === "running") return toolPart(id, "shell", state, { command }, { title: command, output })
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } }) if (state === "error") return toolPart(id, "shell", state, { command }, { error: output || undefined })
if (state === "error") return toolPart(id, "shell", state, { command }, { title: command, output })
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
} }
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant { export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
@@ -655,7 +663,7 @@ function messageContent(
): SessionMessageAssistant["content"][number] { ): SessionMessageAssistant["content"][number] {
if (part.type === "tool") { if (part.type === "tool") {
partRefs.set(part.id, { messageID, type: part.type }) partRefs.set(part.id, { messageID, type: part.type })
toolStates.set(part.callID, part.state.status) toolStates.set(part.id, part.state.status)
} else { } else {
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ }) partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
startedParts.add(part.id) startedParts.add(part.id)
@@ -675,8 +683,8 @@ function messageContent(
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
const base = { const base = {
type: "tool" as const, type: "tool" as const,
id: part.callID, id: part.id,
name: part.tool, name: part.name,
time: { time: {
created: time?.start ?? 1700000001000, created: time?.start ?? 1700000001000,
...(time?.start === undefined ? {} : { ran: time.start }), ...(time?.start === undefined ? {} : { ran: time.start }),
@@ -686,11 +694,18 @@ function messageContent(
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}), ...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}), ...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
} }
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } } if (state.status === "streaming") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "running") if (state.status === "running")
return { return {
...base, ...base,
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) }, state: {
status: "running",
input: jsonRecord(state.input),
metadata: jsonRecord({
...state.metadata,
...(state.output === undefined ? {} : { output: state.output }),
}),
},
} }
if (state.status === "error") if (state.status === "error")
return { return {
@@ -714,7 +729,7 @@ function messageContent(
} }
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] { function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
const previous = toolStates.get(part.callID) const previous = toolStates.get(part.id)
if (previous === "completed" || previous === "error") return [] if (previous === "completed" || previous === "error") return []
const events: OpenCodeEvent[] = [] const events: OpenCodeEvent[] = []
@@ -723,27 +738,27 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.input.started", { makeEvent("session.tool.input.started", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
name: part.tool, name: part.name,
}), }),
) )
} }
if (part.state.status === "pending") { if (part.state.status === "streaming") {
toolStates.set(part.callID, part.state.status) toolStates.set(part.id, part.state.status)
return events return events
} }
if (!previous || previous === "pending") { if (!previous || previous === "streaming") {
events.push( events.push(
makeEvent("session.tool.input.ended", { makeEvent("session.tool.input.ended", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
text: JSON.stringify(part.state.input), text: JSON.stringify(part.state.input),
}), }),
makeEvent("session.tool.called", { makeEvent("session.tool.called", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
input: part.state.input, input: part.state.input,
executed: part.executed ?? true, executed: part.executed ?? true,
state: jsonRecord(part.providerState), state: jsonRecord(part.providerState),
@@ -751,16 +766,20 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
) )
} }
if (part.state.status === "running") { if (part.state.status === "running") {
if (previous === "running" || Object.keys(part.state.metadata).length) const metadata = {
...part.state.metadata,
...(part.state.output === undefined ? {} : { output: part.state.output }),
}
if (previous === "running" || Object.keys(metadata).length)
events.push( events.push(
makeEvent("session.tool.progress", { makeEvent("session.tool.progress", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
metadata: jsonRecord(part.state.metadata), metadata: jsonRecord(metadata),
}), }),
) )
toolStates.set(part.callID, part.state.status) toolStates.set(part.id, part.state.status)
return events return events
} }
if (part.state.status === "error") { if (part.state.status === "error") {
@@ -768,28 +787,28 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.failed", { makeEvent("session.tool.failed", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
error: { type: "ToolError", message: part.state.error }, error: { type: "ToolError", message: part.state.error },
metadata: jsonRecord(part.state.metadata), metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true, executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState), resultState: jsonRecord(part.providerResultState),
}), }),
) )
toolStates.set(part.callID, part.state.status) toolStates.set(part.id, part.state.status)
return events return events
} }
events.push( events.push(
makeEvent("session.tool.success", { makeEvent("session.tool.success", {
sessionID, sessionID,
assistantMessageID: messageID, assistantMessageID: messageID,
id: part.callID, id: part.id,
content: [{ type: "text", text: part.state.output }], content: [{ type: "text", text: part.state.output }],
metadata: jsonRecord(part.state.metadata), metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true, executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState), resultState: jsonRecord(part.providerResultState),
}), }),
) )
toolStates.set(part.callID, part.state.status) toolStates.set(part.id, part.state.status)
return events return events
} }
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import { import {
defineVisualRegions, defineVisualRegions,
reportVisualStability, reportVisualStability,
@@ -58,14 +59,14 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true") await expect(trigger).toHaveAttribute("aria-expanded", "true")
await page.waitForTimeout(500) await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
const expanded = await stopVisualProbe<keyof typeof regions>(page) const expanded = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-expand", expanded, plan) await reportVisualStability(testInfo, "shell-expand", expanded, plan)
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false") await expect(trigger).toHaveAttribute("aria-expanded", "false")
await page.waitForTimeout(500) await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
const collapsed = await stopVisualProbe<keyof typeof regions>(page) const collapsed = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan) await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
}) })
@@ -83,7 +84,7 @@ test("expands and collapses a completed context group without overlap", async ({
messages: [ messages: [
userMessage(), userMessage(),
assistantMessage([ assistantMessage([
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }), toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
toolPart(ids[3]!, "list", "completed", { path: "src" }), toolPart(ids[3]!, "list", "completed", { path: "src" }),
@@ -110,7 +111,7 @@ test("expands and collapses a completed context group without overlap", async ({
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await page.waitForTimeout(500) await waitForVisualSettle(page, [regions.context.selector, regions.following.selector])
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
@@ -142,16 +143,23 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
editID, editID,
"edit", "edit",
"completed", "completed",
{ filePath: "src/edit.ts" }, { path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{ {
metadata: { metadata: {
filediff: { files: [
file: "src/edit.ts", {
additions: 40, file: "src/edit.ts",
deletions: 40, patch: createTwoFilesPatch(
before: source(40, false), "a/src/edit.ts",
after: source(40, true), "b/src/edit.ts",
}, source(40, false),
source(40, true),
),
additions: 40,
deletions: 40,
status: "modified",
},
],
}, },
}, },
), ),
@@ -182,7 +190,7 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await trigger.click() await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true") await expect(trigger).toHaveAttribute("aria-expanded", "true")
await page.waitForTimeout(900) await waitForVisualSettle(page, [regions.edit.selector, regions.following.selector])
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
@@ -17,32 +17,32 @@ import {
userMessage, userMessage,
} from "./fixture" } from "./fixture"
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => { test("adds a subagent child-session link without replacing the row", async ({ page }, testInfo) => {
const taskID = "prt_task_link" const taskID = "prt_subagent_link"
const childID = "ses_task_child" const childID = "ses_subagent_child"
const input = { description: "Inspect child", subagent_type: "explore" } const input = { description: "Inspect child", agent: "explore", prompt: "Inspect the child Session." }
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })], messages: [userMessage(), assistantMessage([toolPart(taskID, "subagent", "running", input)], { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })], sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
cpuRate: 4, cpuRate: 4,
}) })
const regions = defineVisualRegions({ const regions = defineVisualRegions({
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` }, subagent: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
}) })
await startVisualProbe(page, regions) await startVisualProbe(page, regions)
await timeline.send( await timeline.send(
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })), partUpdated(toolPart(taskID, "subagent", "completed", input, { metadata: { sessionID: childID } })),
500, 500,
) )
const trace = await stopVisualProbe<keyof typeof regions>(page) const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability( await reportVisualStability(
testInfo, testInfo,
"task-link", "subagent-link",
trace, trace,
visualPlan(regions, [ visualPlan(regions, [
{ type: "required", regions: ["task"] }, { type: "required", regions: ["subagent"] },
{ type: "unique", regions: ["task"] }, { type: "unique", regions: ["subagent"] },
{ type: "stable", regions: ["task"] }, { type: "stable", regions: ["subagent"] },
{ type: "opacity", regions: "all" }, { type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" }, { type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 }, { type: "motion", regions: "all", maxPositionReversals: 0 },

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