Compare commits

...
Author SHA1 Message Date
Aiden Cline beab7df087 fix(codemode): enumerate inherited keys in for...in, intern tool references, freeze template objects, and delegate Array and Error toString 2026-09-25 12:01:22 -05:00
Shoubhit Dash 14fc63ba9e fix(core): keep thinking on for Claude Opus 5.5 variants (#51338) 2026-09-25 18:32:41 +05:30
opencode-agent[bot]andnexxeln c34ffa117e fix(ai): preserve Gemini 3.8 TTS WAV output (#51300)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-25 18:11:36 +05:30
beeb14e910 feat(prompt): undo queued prompts back into the input (#51124)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
Co-authored-by: vimtor <vn4varro@gmail.com>
2026-09-25 14:36:29 +02:00
opencode-agent[bot] aae42e2e75 chore(core): refresh bundled models.dev snapshot 2026-09-25 12:21:04 +00:00
cc9011c1ae fix(tui): virtualize large added-file diffs (#51122)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
Co-authored-by: vimtor <vn4varro@gmail.com>
2026-09-25 13:51:00 +02:00
Victor Navarro 6cd938e1e9 feat(core): register Console-hosted MCP servers (#51325) 2026-09-25 13:16:04 +02:00
Jack 7de6b3fc15 docs(console): document Qwen3.8 Max (#51320) 2026-09-25 19:13:45 +08:00
opencode-agent[bot] c1c9a13993 chore: update nix node_modules hashes 2026-09-25 08:37:39 +00:00
Simon Klee 917d904f18 tui: update OpenTUI v0.5.12 (#50567) 2026-09-25 08:17:14 +00:00
opencode-agent[bot]andBrendonovich ee5b67eb84 fix(app): unify session project icon resolution (#51288)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-25 07:47:12 +00:00
Aiden Cline 048a47e89e docs: focus compaction page on user-facing behavior (#51270) 2026-09-25 00:21:17 -05:00
Aiden Cline 5335347e80 feat(codemode): add WeakMap and WeakSet (#51257) 2026-09-25 00:15:19 -05:00
Aiden Cline 16b18dff13 Revert "fix(core): fit model limits and recover compaction overflow" (#51273) 2026-09-25 00:12:14 -05:00
Aiden Cline 61c2349cef fix(core): fit model limits and recover compaction overflow (#51238) 2026-09-25 00:11:40 -05:00
opencode-agent[bot]andBrendonovich 684721efb8 feat(app): add provider account switching (#51266)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-25 05:03:33 +00:00
Aiden Cline 962c14a49c fix(codemode): honor thisArg, program toString in computed keys, and ToPrimitive in String and Number arguments (#51264) 2026-09-24 23:46:15 -05:00
opencode-agent[bot]andrekram1-node 85b98e7da4 fix(tui): handle storage watcher errors after startup (#51243)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-24 20:25:06 -05:00
Aiden Cline 8061220b08 test(codemode): vendor every eligible test262 directory and bound unsupported globals (#51242) 2026-09-24 20:19:24 -05:00
Luke Parker b02cc35f13 fix(desktop): keep browser page visible under floating content (#51240) 2026-09-25 10:35:17 +10:00
Aiden Cline e23d89c9a9 fix(codemode): destructure object patterns from primitives and convert Date components through ToPrimitive (#51239) 2026-09-24 19:32:19 -05:00
e8b3e19e85 fix(tui): don't crash when fs.watch throws (e.g. ENOSPC) (#51210)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:16:43 -05:00
Aiden Cline 5256f30957 feat(codemode): honor program valueOf and toString in operators and conversions (#50837) 2026-09-24 18:45:48 -05:00
Aiden Cline 61ecf404b9 fix(core): apply GPT verbosity defaults at request time (#51166) 2026-09-24 17:13:39 -05:00
Shoubhit Dash 92d2b1700f refactor(ai): one media client shape and route types erased over options (#51226) 2026-09-25 03:15:45 +05:30
Aiden Cline 56262121ee feat(codemode): bind this and arguments in functions, add Function.prototype.call, apply, and bind (#50831) 2026-09-24 16:34:46 -05:00
Aiden Cline e3b588e7d2 refactor(ai): apply tool schema rules once per request (#51162) 2026-09-24 16:27:30 -05:00
opencode-agent[bot]andnexxeln 1de648cb13 feat(ai): restore direct LLM input overloads (#51211)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-25 02:49:07 +05:30
Aiden Cline 03be7f385b fix(core): accept 128-character tool names (#51207) 2026-09-24 14:59:57 -05:00
Aiden Cline 8118690839 fix(core): show line counts when tool output is truncated (#51200) 2026-09-24 14:50:55 -05:00
Aiden Cline a16eedfed7 fix(codemode): make derived error constructors inherit from Error (#51045) 2026-09-24 14:03:10 -05:00
Aiden Cline e796f2f9a5 fix(core): read provider errors from plain AI SDK stream errors (#51194) 2026-09-24 13:52:32 -05:00
Aiden Cline 20610e6645 fix(ai): fit Claude thinking budgets on Bedrock Converse (#51190) 2026-09-24 13:48:39 -05:00
Aiden Cline 7f245b0968 fix(ai): fit OpenRouter reasoning budgets to the output limit (#51189) 2026-09-24 13:42:10 -05:00
Shoubhit Dash 7013e925f5 refactor(ai): keep LLM calls request-only in the promise client (#51180) 2026-09-24 23:47:42 +05:30
Aiden Cline 499c2feaa3 fix(core): cap Alibaba thinking budget variants at 64k (#51154) 2026-09-24 13:14:49 -05:00
Aiden Cline 03af821aa5 fix(core): restore the shell no-output placeholder (#51187) 2026-09-24 13:12:42 -05:00
Aiden Cline c903774556 fix(ai): fit thinking budgets to the output limit (#51157) 2026-09-24 12:58:54 -05:00
James Long 14aaf91e65 fix(tui): mark failed groups with a plain ✗ (#51175) 2026-09-24 13:52:14 -04:00
James Long c832432d89 refactor(tui): drop unused yellow alias from opencode theme (#51177) 2026-09-24 13:17:21 -04:00
opencode-agent[bot]andjlongster 1d431a80df fix(cli): reuse core declarations during typecheck (#51165)
Co-authored-by: jlongster <jlongster@users.noreply.github.com>
2026-09-24 13:16:46 -04:00
190 changed files with 6382 additions and 1770 deletions
+16 -16
View File
@@ -597,8 +597,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"@opentui/core": ">=0.5.12",
"@opentui/solid": ">=0.5.12",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1114,9 +1114,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2252,27 +2252,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-+Clo0VPDdruHSoBNvV/wKAM8iR6HJPtB00oa8yl9ujU=",
"aarch64-linux": "sha256-4wU5v36GTXjwyt5ls4FH+5G43Ujd+dKVSJR21w3lhbA=",
"aarch64-darwin": "sha256-pThjoD6baddQ6biy7k1ByXwGwLAeWe/+w0tcYmt1uWs=",
"x86_64-darwin": "sha256-bCBl63CqBiqilb+YdaOLBYYZx/yf47c1aqgDOkgdegg="
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
}
}
+3 -3
View File
@@ -52,9 +52,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
+3 -2
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
@@ -102,7 +102,7 @@ Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. `ImageRoute` is the same union; both clients dispatch through `MediaRoute.dispatch` and models compose through `composeAnyRoute`, and fal queue protocols come from `protocols/utils/fal-queue.ts`. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
@@ -275,6 +275,7 @@ Use this order for every protocol module:
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
+12 -12
View File
@@ -9,15 +9,13 @@ import { OpenAI } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
const program = Effect.gen(function* () {
const response = yield* LLM.generate(request)
const response = yield* LLM.generate({
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
console.log(response.text)
})
@@ -25,7 +23,8 @@ const program = Effect.gen(function* () {
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
```
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt
`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
@@ -72,10 +71,11 @@ helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Ef
import { AI } from "@opencode/ai/promise"
const ai = AI.make()
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." }
const text = await ai.llm.generate(input)
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
for await (const event of ai.llm.stream(ai.llm.request(input))) {
// LLMEvent
}
await ai.dispose()
@@ -936,7 +936,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
+5 -4
View File
@@ -140,7 +140,7 @@ portability matrix.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
#### Video
@@ -215,7 +215,7 @@ const request = Speech.request({
})
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
yield* Speech.stream(request) // Stream<SpeechEvent>: audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
```
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
@@ -389,7 +389,8 @@ for await (const event of generation.events({ poll: { interval: 10_000 } })) {
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const text = await ai.llm.generate({ model, prompt })
const request = ai.llm.request({ model, prompt })
const text = await ai.llm.generate(request)
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
@@ -419,7 +420,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
### Routes and protocols
+16 -85
View File
@@ -1,99 +1,30 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
ImageOutputEvent,
ImageFinishEvent,
type ImageEvent,
type ImageModel,
type ImageOptions,
type ImageRequestFor,
type ImageResponse,
} from "./image.js"
export interface Interface {
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<ImageResponse, AIError>
readonly stream: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<ImageEvent, AIError>
readonly start: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<Generation<ImageResponse>, AIError>
readonly resume: <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export type Interface = MediaClient.Interface<ImageRequestFor, ImageEvent, ImageResponse>
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
export const Service = ImageClientService
export type Service = ImageClientService
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const start = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<ImageEvent, ImageResponse>({
modality: "image",
execute: executor.execute,
responseEvents,
})
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
})
}),
)
export const ImageClient = {
Service,
layer,
generate,
stream,
start,
resume,
...MediaClient.make(Service, {
modality: "image",
responseEvents: (response: ImageResponse) => [
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
ImageFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+14 -65
View File
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { ImageClient, Service } from "./image-client.js"
@@ -11,75 +10,39 @@ import { ImageClient, Service } from "./image-client.js"
// Model
// ---------------------------------------------------------------------------
export type ImageOptions = Record<string, unknown>
export type ImageOptions = MediaModel.Options
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.AnyRoute<
ImageRequestFor<Options>,
ImageEvent,
ImageResponse
>
export type ImageRoute = MediaRoute.AnyRoute<ImageRequestFor, ImageEvent, ImageResponse>
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute<Options>, Options> {
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute, Options> {
declare protected readonly _ImageModel: void
static make<Options extends ImageOptions = ImageOptions>(input: MediaModel.Input<ImageRoute<Options>>) {
return new ImageModel<Options>(input)
}
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends ImageOptions>(
route: ImageModel.InlineRouteInput<Options>,
route: MediaModel.InlineRouteInput<ImageRequestFor<Options>, ImageResponse>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State>(
route: ImageModel.StreamRouteInput<Options, Frame, State>,
route: MediaModel.StreamRouteInput<ImageRequestFor<Options>, ImageEvent, Frame, State>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Token>(
route: ImageModel.QueuedRouteInput<Options, Token>,
route: MediaModel.QueuedRouteInput<ImageRequestFor<Options>, ImageResponse, Token>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State, Token>(
route: ImageModel.RouteInput<Options, Frame, State, Token>,
route: MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new ImageModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composeRoute(route, input, collectResponse) as ImageRoute,
})
}
}
export namespace ImageModel {
export type InlineRouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
>
export type StreamRouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<ImageRequestFor<Options>>,
MediaProtocol.Streamed<ImageRequestFor<Options>, ImageEvent, Frame, State>
>
export type QueuedRouteInput<Options extends ImageOptions = ImageOptions, Token = unknown> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Queued<ImageRequestFor<Options>, ImageResponse, Token>
>
export type RouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
@@ -190,15 +153,6 @@ export const ImageEvent = Object.assign(imageEventTagged, {
})
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEvent> => [
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
ImageFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
]
const collectResponse = (events: ReadonlyArray<ImageEvent>): Effect.Effect<ImageResponse> => {
const finish = events.find(ImageEvent.is.finish)
// Every image protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
@@ -232,36 +186,31 @@ export function request(input: ImageRequest | ImageRequestInput) {
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest, options?: AwaitOptions): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options)))
}
export function stream<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest, options?: AwaitOptions): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options))))
}
/** Inline and streaming routes fail with `UnsupportedOperation`. */
export function start<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest | ImageRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request)))
}
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> => ImageClient.resume(model, token)
export const resume = (model: ImageModel, token: unknown): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
ImageClient.resume(model, token)
export const Image = {
request,
+22 -4
View File
@@ -1,5 +1,6 @@
import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient, Service } from "./route/client.js"
import { Effect, JsonSchema, Schema, Stream } from "effect"
import { tryRequest } from "./media-model.js"
import { LLMClient, Service, type StreamOptions } from "./route/client.js"
import {
GenerationOptions,
HttpOptions,
@@ -35,9 +36,26 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly http?: HttpOptions.Input
}
export const generate = LLMClient.generate
export function generate<const Model extends LanguageModel>(
input: RequestInput<Model>,
options?: StreamOptions,
): Effect.Effect<LLMResponse, AIError, Service>
export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service>
export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options)))
}
export const stream = LLMClient.stream
export function stream<const Model extends LanguageModel>(
input: RequestInput<Model>,
options?: StreamOptions,
): Stream.Stream<LLMEvent, AIError, Service>
export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service>
export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options))))
}
const requestEffect = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => request(input))
export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
+77
View File
@@ -0,0 +1,77 @@
import { type Context, Effect, Layer, Stream } from "effect"
import { resultEvents, type AwaitOptions, type Generation, type Observation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import type { MediaRoute } from "./route/media.js"
import { AIError, UnsupportedOperationError } from "./schema/index.js"
/** A media request whose model carries the route that executes it. */
export interface RoutedRequest<Self extends MediaRoute.MediaRequest, Event, Response> extends MediaRoute.MediaRequest {
readonly model: MediaRoute.MediaRequest["model"] & { readonly route: MediaRoute.AnyRoute<Self, Event, Response> }
}
/** `start` and `resume` fail with `UnsupportedOperation` on inline and stream routes. */
export interface Interface<Req extends RoutedRequest<Req, Event, Response>, Event, Response> {
readonly generate: (request: Req, options?: AwaitOptions) => Effect.Effect<Response, AIError>
readonly stream: (request: Req, options?: AwaitOptions) => Stream.Stream<Event | Observation, AIError>
readonly start: (request: Req) => Effect.Effect<Generation<Response>, AIError>
readonly resume: (model: Req["model"], token: unknown) => Effect.Effect<Generation<Response>, AIError>
}
/** One modality's layer and service accessors, dispatching each request on its route's `kind`. */
export const make = <Self, Req extends RoutedRequest<Req, Event, Response>, Event, Response>(
service: Context.Service<Self, Interface<Req, Event, Response>>,
input: {
readonly modality: string
/** A completed response expanded into the streaming event shape. */
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
},
) => ({
layer: Layer.effect(
service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const notQueued = (route: MediaRoute.AnyRoute<Req, Event, Response>, operation: string) =>
new AIError({
reason: new UnsupportedOperationError({
operation: `${input.modality}.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
}),
})
const start = (request: Req) => {
const route = request.model.route
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, executor.execute)
}
return service.of({
start,
resume: (model, token) => {
if (model.route.kind !== "queued") return Effect.fail(notQueued(model.route, "resume"))
return model.route.resume(model, token, executor.execute)
},
generate: (request, options) => {
const route = request.model.route
if (route.kind !== "queued") return route.generate(request, executor.execute)
return start(request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: (request, options) => {
const route = request.model.route
if (route.kind === "stream") return route.stream(request, executor.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(request).pipe(Effect.map((generation) => resultEvents(generation, input.responseEvents, options))),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, executor.execute), input.responseEvents))
},
})
}),
),
generate: (request: Req, options?: AwaitOptions) => service.use((client) => client.generate(request, options)),
stream: (request: Req, options?: AwaitOptions) =>
Stream.unwrap(service.useSync((client) => client.stream(request, options))),
start: (request: Req) => service.use((client) => client.start(request)),
resume: (model: Req["model"], token: unknown) => service.use((client) => client.resume(model, token)),
})
export * as MediaClient from "./media-client.js"
+42 -30
View File
@@ -6,11 +6,13 @@ import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "
/**
* What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
* (`ImageModel`, `VideoModel`, `SpeechModel`) extend it with their route type and a nominal marker so one cannot stand
* in for the other in requests.
* (`ImageModel`, `VideoModel`, `SpeechModel`, `TranscriptionModel`) extend it with their route type and a nominal
* marker so one cannot stand in for the other in requests.
*/
export class MediaModel<Route, Options> {
declare protected readonly _Options: (options: Options) => Options
// As with `LanguageModel`, the route type is erased over `Options`; `fromRoute` and the constructor trust that the
// route accepts every request this model's `Options` admit.
declare protected readonly _Options: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: Route
@@ -25,6 +27,8 @@ export class MediaModel<Route, Options> {
}
export namespace MediaModel {
export type Options = Record<string, unknown>
export interface Input<Route> {
readonly id: string | ModelID
readonly provider: string | ProviderID
@@ -41,48 +45,56 @@ export namespace MediaModel {
readonly headers?: Record<string, string>
}
export type InlineRouteInput<Request extends MediaRoute.MediaRequest, Response> = RouteInput<
Request,
MediaProtocol.Inline<Request, Response>
>
export type StreamRouteInput<Request extends MediaRoute.MediaRequest, Event, Frame, State> = RouteInput<
MediaProtocol.Addressed<Request>,
MediaProtocol.Streamed<Request, Event, Frame, State>
>
export type QueuedRouteInput<Request extends MediaRoute.MediaRequest, Response, Token> = RouteInput<
Request,
MediaProtocol.Queued<Request, Response, Token>
>
export type AnyRouteInput<Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token> =
| RouteInput<Request, MediaProtocol.Inline<Request, Response>>
| RouteInput<MediaProtocol.Addressed<Request>, MediaProtocol.Streamed<Request, Event, Frame, State>>
| RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>>
| InlineRouteInput<Request, Response>
| StreamRouteInput<Request, Event, Frame, State>
| QueuedRouteInput<Request, Response, Token>
}
/** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol, Route>(
compose: (input: MediaRoute.Composition<Request> & { readonly protocol: Protocol }) => Route,
route: MediaModel.RouteInput<Request, Protocol>,
input: MediaRoute.ModelInput,
): Route =>
compose({
protocol: route.protocol,
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
auth: input.auth,
headers:
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
})
export const composeAnyRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
export const composeRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
input: MediaRoute.ModelInput,
collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>,
): MediaRoute.AnyRoute<Request, Event, Response> => {
if (isStreamInput(route))
return composeRoute((composition) => MediaRoute.stream({ ...composition, collect }), route, input)
if (isQueuedInput(route)) return composeRoute(MediaRoute.queued, route, input)
return composeRoute(MediaRoute.inline, route, input)
if (isStreamInput(route)) return MediaRoute.stream({ ...composition(route, input), collect })
if (isQueuedInput(route)) return MediaRoute.queued(composition(route, input))
return MediaRoute.inline(composition(route, input))
}
const composition = <Request extends MediaRoute.MediaRequest, Protocol>(
route: MediaModel.RouteInput<Request, Protocol>,
input: MediaRoute.ModelInput,
): MediaRoute.Composition<Request> & { readonly protocol: Protocol } => ({
protocol: route.protocol,
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
auth: input.auth,
headers:
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
})
const isStreamInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<
MediaProtocol.Addressed<Request>,
MediaProtocol.Streamed<Request, Event, Frame, State>
> => route.protocol.kind === "stream"
): route is MediaModel.StreamRouteInput<Request, Event, Frame, State> => route.protocol.kind === "stream"
const isQueuedInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>> =>
route.protocol.kind === "queued"
): route is MediaModel.QueuedRouteInput<Request, Response, Token> => route.protocol.kind === "queued"
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
+20 -33
View File
@@ -1,23 +1,22 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import { AIClient } from "./ai-client.js"
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
import { Image, type ImageModel, type ImageRequest, type ImageRequestInput } from "./image.js"
import { LLM } from "./index.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import { RequestExecutor } from "./route/executor.js"
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
import type { RequestInput } from "./llm.js"
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
import { Speech, type SpeechModel, type SpeechRequest, type SpeechRequestInput } from "./speech.js"
import {
Transcription,
TranscriptionModel,
TranscriptionRequest,
type TranscriptionOptions,
type TranscriptionModel,
type TranscriptionRequest,
type TranscriptionRequestInput,
} from "./transcription.js"
import { fileMediaType } from "./utils/media-type.js"
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js"
/**
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
@@ -93,17 +92,8 @@ export const make = (options: Options = {}) => {
cancel: (options) => run(generation.cancel(), options),
})
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
const llmRequest = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
input instanceof ImageRequest ? input : Image.request(input)
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
input instanceof VideoRequest ? input : Video.request(input)
const speechRequest = (input: SpeechRequestInput | SpeechRequest) =>
input instanceof SpeechRequest ? input : Speech.request(input)
const transcriptionRequest = (input: TranscriptionRequestInput | TranscriptionRequest) =>
input instanceof TranscriptionRequest ? input : Transcription.request(input)
return {
run,
@@ -155,61 +145,58 @@ export const make = (options: Options = {}) => {
generate: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => run(Image.generate(imageRequest(input), { poll: options?.poll }), options),
) => run(Image.generate(input, { poll: options?.poll }), options),
stream: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Image.stream(imageRequest(input), { poll: options?.poll }), options),
) => iterate(Image.stream(input, { poll: options?.poll }), options),
start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
run(Image.start(imageRequest(input)), options).then(handle),
resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) =>
run(Image.start(input), options).then(handle),
resume: (model: ImageModel, token: unknown, options?: RunOptions) =>
run(Image.resume(model, token), options).then(handle),
},
video: {
request: Video.request,
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
run(Video.start(videoRequest(input)), options).then(handle),
run(Video.start(input), options).then(handle),
generate: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => run(Video.generate(videoRequest(input), { poll: options?.poll }), options),
resume: <Options extends VideoOptions>(model: VideoModel<Options>, token: unknown, options?: RunOptions) =>
) => run(Video.generate(input, { poll: options?.poll }), options),
resume: (model: VideoModel, token: unknown, options?: RunOptions) =>
run(Video.resume(model, token), options).then(handle),
stream: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Video.stream(videoRequest(input), { poll: options?.poll }), options),
) => iterate(Video.stream(input, { poll: options?.poll }), options),
},
speech: {
request: Speech.request,
generate: <const Model extends SpeechModel>(
input: SpeechRequestInput<Model> | SpeechRequest,
options?: RunOptions,
) => run(Speech.generate(speechRequest(input)), options),
) => run(Speech.generate(input), options),
stream: <const Model extends SpeechModel>(
input: SpeechRequestInput<Model> | SpeechRequest,
options?: RunOptions,
) => iterate(Speech.stream(speechRequest(input)), options),
) => iterate(Speech.stream(input), options),
},
transcription: {
request: Transcription.request,
generate: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: AwaitOptions & RunOptions,
) => run(Transcription.generate(transcriptionRequest(input), { poll: options?.poll }), options),
) => run(Transcription.generate(input, { poll: options?.poll }), options),
stream: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Transcription.stream(transcriptionRequest(input), { poll: options?.poll }), options),
) => iterate(Transcription.stream(input, { poll: options?.poll }), options),
start: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: RunOptions,
) => run(Transcription.start(transcriptionRequest(input)), options).then(handle),
resume: <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
options?: RunOptions,
) => run(Transcription.resume(model, token), options).then(handle),
) => run(Transcription.start(input), options).then(handle),
resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) =>
run(Transcription.resume(model, token), options).then(handle),
},
dispose: () => runtime.dispose(),
}
+5 -1
View File
@@ -70,7 +70,11 @@ export const protocol = Protocol.make({
return {
...(yield* OpenAIChat.protocol.body.from(req)),
enable_thinking: opts.enableThinking,
thinking_budget: opts.thinkingBudget,
// Alibaba also rejects an explicit budget that is not below `max_completion_tokens`.
thinking_budget:
opts.thinkingBudget === undefined
? undefined
: ProviderShared.fitThinkingBudget(opts.thinkingBudget, req.generation?.maxTokens),
preserve_thinking: opts.preserveThinking,
clear_thinking: opts.clearThinking,
thinking: opts.thinking,
@@ -26,18 +26,21 @@ export const protocol = Protocol.make({
from: Effect.fn("AlibabaMessages.fromRequest")(function* (req) {
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
// Model Studio accepts enabled thinking without Anthropic's mandatory token budget.
const body = yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(req, {
providerOptions: { ...req.providerOptions, thinking: undefined },
}),
)
const budget = opts.thinking?.budgetTokens ?? opts.thinking?.budget_tokens
return {
...(yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(req, {
providerOptions: { ...req.providerOptions, thinking: undefined },
}),
)),
...body,
thinking:
opts.thinking === undefined
? undefined
: {
type: opts.thinking.type,
budget_tokens: opts.thinking.budgetTokens ?? opts.thinking.budget_tokens,
budget_tokens:
budget === undefined ? undefined : ProviderShared.fitThinkingBudget(budget, body.max_tokens),
},
}
}),
+16 -12
View File
@@ -18,7 +18,6 @@ import {
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
@@ -31,13 +30,13 @@ import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const MIN_THINKING_BUDGET = 1_024
const DEFAULT_EFFORT = "high"
const SSE_EVENTS = new Set([
@@ -524,10 +523,10 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: s
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: inputSchema,
input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
})
@@ -1027,6 +1026,15 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
// Anthropic also requires an explicit thinking budget below `max_tokens` and at or above its minimum.
const fitThinking = (thinking: AnthropicThinking | undefined, maxTokens: number) =>
thinking?.type === "enabled"
? {
...thinking,
budget_tokens: ProviderShared.fitThinkingBudget(thinking.budget_tokens, maxTokens, MIN_THINKING_BUDGET),
}
: thinking
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
@@ -1039,12 +1047,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const tools =
flattened.tools.length === 0
? undefined
: flattened.tools.map((tool) =>
lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool))
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter((part) => part.text.length > 0)
@@ -1064,6 +1067,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
const maxTokens = generation?.maxTokens ?? DEFAULT_MAX_TOKENS
const body = {
model: request.model.id,
system,
@@ -1071,12 +1075,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: applyThinkingBindingDefault(request.model, fitThinking(options.thinking, maxTokens)),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
+34 -14
View File
@@ -9,7 +9,6 @@ import {
type CacheHint,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type LanguageModel,
type ProviderMetadata,
@@ -26,7 +25,6 @@ import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
import { concatBytes } from "../utils/bytes.js"
@@ -221,22 +219,18 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// =============================================================================
// Request Lowering
// =============================================================================
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
inputSchema: { json: inputSchema },
inputSchema: { json: tool.inputSchema },
},
})
const lowerTools = (
model: LanguageModel,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
const result: BedrockTool[] = []
for (const tool of tools) {
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)))
result.push(lowerToolSpec(tool))
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint)
}
@@ -441,19 +435,39 @@ const isHighReasoningEffort = Schema.is(
}),
)
const Options = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })),
})
export type OptionsInput = typeof Options.Type
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
const MIN_THINKING_BUDGET = 1_024
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
const thinking =
options.thinking === undefined
? undefined
: {
type: "enabled",
budget_tokens: ProviderShared.fitThinkingBudget(
options.thinking.budgetTokens,
maxTokens,
MIN_THINKING_BUDGET,
),
}
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
tools: lowerTools(request.model, breakpoints, flattened.tools),
tools: lowerTools(breakpoints, flattened.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
@@ -487,9 +501,15 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
system,
inferenceConfig,
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
// Converse's base inferenceConfig has no topK or thinking; Anthropic/Nova accept them
// as model-specific fields, so they go through additionalModelRequestFields.
additionalModelRequestFields:
generation?.topK === undefined && thinking === undefined
? undefined
: {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
},
}
})
+21 -8
View File
@@ -11,7 +11,6 @@ import {
Usage,
type FinishReason,
type LLMRequest,
type LanguageModel,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
@@ -24,11 +23,12 @@ import { Media } from "../media.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
const ADAPTER = "gemini"
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
const MIN_THINKING_BUDGET = 512
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
@@ -268,12 +268,11 @@ interface ParserState {
// =============================================================================
// Request Lowering
// =============================================================================
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. Gemini's schema
// rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
const lowerTool = (tool: ToolDefinition, model: LanguageModel) => ({
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema.
const lowerTool = (tool: ToolDefinition) => ({
name: tool.name,
description: tool.description,
parametersJsonSchema: ToolSchemaProjection.modelCompatibility(tool.inputSchema, model, "gemini"),
parametersJsonSchema: tool.inputSchema,
})
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -452,10 +451,22 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
// Gemini accepts a budget above `maxOutputTokens`, but thinking then leaves the answer empty.
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
: {
...options.thinkingConfig,
includeThoughts: options.thinkingConfig.includeThoughts ?? true,
thinkingBudget:
options.thinkingConfig.thinkingBudget === undefined
? undefined
: ProviderShared.fitThinkingBudget(
options.thinkingConfig.thinkingBudget,
generation?.maxTokens,
MIN_THINKING_BUDGET,
),
},
}
return {
@@ -468,7 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
tools: hasTools
? [
{
functionDeclarations: flattened.tools.map((tool) => lowerTool(tool, request.model)),
functionDeclarations: flattened.tools.map(lowerTool),
},
]
: undefined,
@@ -804,6 +815,8 @@ export const protocol = Protocol.make({
schema: GeminiBody,
from: fromRequest,
},
// Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
sanitizer: "gemini",
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
+16 -3
View File
@@ -56,10 +56,15 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
// ---------------------------------------------------------------------------
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
return yield* route.unsupported(
"media.format",
`${route.name} returns WAV by default for Gemini 3.8 TTS unary requests; omit the format to accept it`,
)
if (request.format !== undefined && request.format !== "pcm")
return yield* route.unsupported(
"media.format",
`${route.name} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
`${route.name} only accepts raw PCM as an explicit format; omit it to accept the provider's default output`,
)
const voiceName = SpeechStream.voiceID(request.voice)
return MediaProtocol.json(
@@ -97,10 +102,18 @@ const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: stri
return [next, audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])] as const
})
const finish = (state: State) => {
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
const output =
state.mimeType?.split(";")[0]?.toLowerCase() === "audio/wav"
? SpeechStream.container("wav", sampleRate)
: SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`)
if (context.request.format === "pcm" && output.info.format !== "pcm")
return Effect.fail(
route.frameError(`Google Speech returned ${output.info.format} instead of the requested raw PCM`),
)
return SpeechStream.finish(route, state, {
...SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`),
...output,
usage: GeminiGenerateContent.usage(state.usage),
providerMetadata: GeminiGenerateContent.providerMetadata(state),
detail: state.finishReason === undefined ? undefined : `finish reason: ${state.finishReason}`,
+1 -7
View File
@@ -5,7 +5,6 @@ import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.j
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { detectMediaType } from "../utils/media-type.js"
const ADAPTER = "meta-responses"
@@ -103,12 +102,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
Effect.gen(function* () {
if (tool.native === undefined)
return yield* OpenResponses.lowerTool(
NAME,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
)
if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
}),
),
+3 -10
View File
@@ -13,7 +13,6 @@ import {
UnknownProviderError,
Usage,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ToolCallPart,
@@ -23,7 +22,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
@@ -368,9 +366,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
return messages
})
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({
const lowerTool = (tool: ToolDefinition): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false },
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
@@ -396,12 +394,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request),
tools:
flattened.tools.length > 0
? flattened.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
: undefined,
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
+8 -16
View File
@@ -8,7 +8,6 @@ import {
ProviderInternalError,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
@@ -24,7 +23,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "open-responses"
@@ -443,23 +441,24 @@ interface ReasoningStreamItem {
// =============================================================================
// Request Lowering
// =============================================================================
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
protocolName: string,
tool: ToolDefinition,
inputSchema: JsonSchema,
) {
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
if (tool.native !== undefined)
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: tool.inputSchema,
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
})
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
Effect.forEach(tools, (tool) =>
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
)
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice(protocolName, toolChoice, {
auto: () => "auto" as const,
@@ -821,14 +820,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
return {
...(yield* lowerConversation(projected.request, adapter)),
...lowerGeneration(request),
tools:
projected.tools.length === 0
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
tool.native !== undefined && adapter.nativeTool
? adapter.nativeTool(tool.native)
: lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
),
tools: projected.tools.length === 0 ? undefined : yield* lowerTools(projected.tools, adapter),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
+3 -17
View File
@@ -17,7 +17,6 @@ import {
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ReasoningPart,
@@ -29,7 +28,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIOptions } from "./utils/openai-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
@@ -330,17 +328,12 @@ interface LoweringOptions {
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: tool.inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
@@ -825,14 +818,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
? hasHistory
? []
: undefined
: flattened.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
options,
supportsStrictMode,
),
),
: flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)),
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
+20 -38
View File
@@ -5,20 +5,12 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import {
LLMRequest,
mergeJsonRecords,
type JsonSchema,
type LanguageModel,
type ToolDefinition,
type ToolEntry,
} from "../schema/index.js"
import { LLMRequest, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
@@ -143,11 +135,6 @@ export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compactio
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
@@ -174,20 +161,19 @@ const nativeImageTool = (tool: ToolDefinition) => {
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
}
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
return yield* OpenResponses.lowerTool(NAME, tool)
})
// Native namespaces hold only function tools, so deeper levels flatten into
// the leaf names the same way non-native protocols flatten the whole tree.
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) {
if (tool.type === "tool")
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
if (tool.type === "tool") return yield* lowerTool(tool)
// OpenAI requires a namespace description; fall back to a generic one so a
// missing description never blocks the request.
return {
@@ -195,11 +181,13 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (to
name: tool.name,
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)),
OpenResponses.lowerTool(NAME, leaf),
),
}
})
const lowerTools = (request: LLMRequest) => Effect.forEach(request.tools, lowerToolEntry)
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
ProviderShared.matchToolChoice(NAME, toolChoice, {
auto: () => "auto" as const,
@@ -223,10 +211,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)),
tools: request.tools.length === 0 ? undefined : yield* lowerTools(request),
tool_choice:
request.tools.length === 0
? undefined
@@ -238,7 +223,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
@@ -249,18 +233,13 @@ const checkpointBody = {
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
if (overlay?.stream !== undefined && overlay.stream !== true)
return yield* ProviderShared.invalidRequest("Trigger compaction requires a streamed response")
const native = yield* fromRequest(request)
return {
...native,
input: [...native.input, { type: "compaction_trigger" as const }],
}
}),
}
@@ -342,7 +321,10 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
compact: {
endpoint: ResponsesCompaction.make(adapter, lowerTools),
trigger: ResponsesCheckpoint.make(checkpointBody),
},
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
+8
View File
@@ -110,6 +110,14 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
}
/**
* Caps an explicit thinking budget at half the output limit. Thinking counts against the output limit, so a budget
* near it leaves the answer, a tool call, or a summary without room. Smaller budgets, special values such as `-1` and
* `0`, and requests without an output limit pass through unchanged.
*/
export const fitThinkingBudget = (budget: number, maxTokens: number | undefined, minimum = 1) =>
maxTokens === undefined || budget <= maxTokens / 2 ? budget : Math.max(minimum, Math.floor(maxTokens / 2))
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({ route, message, body, cause }),
@@ -1,7 +1,7 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { CompactionCheckpointResponse, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
@@ -109,12 +109,8 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
const prepared = yield* route.prepareTransport(native, request, options)
yield* route.streamPrepared(prepared, request, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
@@ -19,12 +19,18 @@ import { OpenResponses } from "../open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
import { Media } from "../../media.js"
// /compact has a smaller wire contract than /responses; keep the request controls it accepts.
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(Schema.Unknown),
instructions: optionalNull(Schema.String),
previous_response_id: optionalNull(Schema.String),
service_tier: optionalNull(Schema.String),
reasoning: Schema.optional(JsonObject),
text: Schema.optional(JsonObject),
include: OpenResponses.coreFields.include,
parallel_tool_calls: OpenResponses.coreFields.parallel_tool_calls,
tools: Schema.optional(Schema.Array(JsonObject)),
prompt_cache_key: optionalNull(Schema.String),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -74,17 +80,27 @@ const Response = Schema.Struct({
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
})
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
export const make = (
adapter: OpenResponses.ProviderAdapter,
lowerTools: (request: LLMRequest) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, AIError>,
): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
// The standalone compaction endpoint rejects histories containing configuration updates.
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
const generation = OpenResponses.lowerGeneration(request)
const tools = request.tools.length === 0 ? undefined : yield* lowerTools(request)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
...native,
service_tier: request.providerOptions?.serviceTier,
prompt_cache_key: ProviderShared.promptCacheKey(request),
service_tier: generation.service_tier,
reasoning: generation.reasoning,
text: generation.text,
include: generation.include,
parallel_tool_calls: generation.parallel_tool_calls,
tools,
prompt_cache_key: generation.prompt_cache_key,
},
request.http?.body,
),
+16 -8
View File
@@ -1,4 +1,4 @@
import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js"
import { ToolDefinition, type JsonSchema, type LanguageModel, type LLMRequest } from "../../schema/index.js"
import { isRecord } from "../../utils/record.js"
import { GeminiJsonSchema } from "./gemini-json-schema.js"
@@ -70,13 +70,13 @@ const objectRoot = (schema: JsonSchema): JsonSchema => {
// Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the
// model name selects the family's rules so models reached through gateways and OpenAI-compatible
// endpoints get the same handling.
const modelCompatibility = (
schema: JsonSchema,
model: LanguageModel,
protocolDefault?: LanguageModelSanitizerCompatibility,
): JsonSchema => {
const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => {
const root = objectRoot(schema)
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
switch (
model.compatibility?.sanitizer ??
model.route.sanitizer ??
MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]
) {
case "gemini":
return gemini(root)
case "moonshot":
@@ -87,10 +87,18 @@ const modelCompatibility = (
}
}
// Applied once to every request before any protocol builds its body, including tools in namespaces.
const tools = (entries: LLMRequest["tools"], model: LanguageModel): LLMRequest["tools"] =>
entries.map((tool) =>
tool.type === "tool"
? new ToolDefinition({ ...tool, inputSchema: modelCompatibility(tool.inputSchema, model) })
: { ...tool, tools: tools(tool.tools, model) },
)
export const ToolSchemaProjection = {
gemini,
modelCompatibility,
moonshot,
openAI,
responses,
tools,
} as const
+5 -2
View File
@@ -50,7 +50,8 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
message:
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -93,6 +94,8 @@ export const protocol = Protocol.make({
},
})
export const compact = ResponsesCompaction.make(adapter)
export const compact = ResponsesCompaction.make(adapter, (request) =>
OpenResponses.lowerTools(ProviderShared.flattenTools(request.tools), adapter),
)
export * as XAIResponses from "./xai-responses.js"
+7 -1
View File
@@ -80,7 +80,13 @@ const SERVER_CODES = new Set([
"slow_down",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
const INVALID_REQUEST_CODES = new Set([
"invalid_prompt",
"invalid_request",
"invalid_request_error",
"validationexception",
])
// Azure OpenAI reports `content_filter` with `innererror.code` ResponsibleAIPolicyViolation.
// OpenRouter tags provider failures with a typed `error_type`; its Responses skin also
// emits `image_content_policy_violation` as the native code.
@@ -31,6 +31,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly thinking?: BedrockConverse.OptionsInput["thinking"]
}
export const routes = [BedrockConverse.route]
@@ -71,6 +72,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.thinking === undefined ? undefined : { thinking: settings.thinking },
profile: settings.profile,
region: settings.region,
}).model(modelID)
+4 -15
View File
@@ -32,10 +32,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
return result
}
export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => {
export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
@@ -47,27 +44,19 @@ export const gpt5DefaultOptions = (
// this, callers using the default model facade get reasoning summaries
// they cannot replay statelessly.
include: ["reasoning.encrypted_content"],
textVerbosity:
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
: undefined,
})
}
export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
options: Options,
defaults: { readonly textVerbosity?: boolean } = {},
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
return {
...options,
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions),
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID), options.providerOptions),
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ export const configure = (input: Config = {}) => {
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.with(withOpenAIOptions(id, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
+11 -4
View File
@@ -8,7 +8,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const id = ProviderID.make("openrouter")
const baseURL = "https://openrouter.ai/api/v1"
@@ -123,7 +123,7 @@ export const protocol = Protocol.make({
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...bodyOptions(request.providerOptions, request.generation?.maxTokens),
} as OpenRouterBody
}),
),
@@ -143,7 +143,14 @@ const cacheControl = () => {
}
}
const bodyOptions = (input: unknown) => {
// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba
// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget.
const fitReasoning = (reasoning: Record<string, unknown>, maxTokens: number | undefined) =>
typeof reasoning.max_tokens === "number"
? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) }
: reasoning
const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
@@ -162,7 +169,7 @@ const bodyOptions = (input: unknown) => {
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}),
}
}
+7 -2
View File
@@ -11,7 +11,8 @@ import { applyEffortUpdates } from "../effort-updates.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import { ToolSchemaProjection } from "../protocols/utils/tool-schema.js"
import type { LanguageModelSanitizerCompatibility, ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
@@ -57,6 +58,7 @@ export interface Route<
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly sanitizer?: LanguageModelSanitizerCompatibility
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -388,6 +390,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
sanitizer: protocol.sanitizer,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
@@ -559,7 +562,9 @@ const prepareRequest = (request: LLMRequest) => {
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
applyEffortUpdates(
LLMRequest.update(sanitized, { tools: ToolSchemaProjection.tools(dedupe(sanitized.tools), sanitized.model) }),
),
)
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
+4 -57
View File
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
import { RequestExecutorService, type Interface } from "./executor-service.js"
import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, resultEvents, type AwaitOptions, type Observation } from "../generation.js"
import { Generation } from "../generation.js"
import type { Media } from "../media.js"
import {
AIError,
@@ -52,7 +52,7 @@ export const deployment = (
// ---------------------------------------------------------------------------
/** One request, one response. */
export interface Route<Request extends MediaRequest, Response> {
export interface InlineRoute<Request extends MediaRequest, Response> {
readonly kind: "inline"
readonly id: string
readonly provider: ProviderID
@@ -86,7 +86,7 @@ export interface StreamRoute<Request extends MediaRequest, Event, Response> {
}
export type AnyRoute<Request extends MediaRequest, Event, Response> =
| Route<Request, Response>
| InlineRoute<Request, Response>
| StreamRoute<Request, Event, Response>
| QueuedRoute<Request, Response>
@@ -119,7 +119,7 @@ export interface StreamInput<Request extends MediaRequest, Event, Response, Fram
*/
export const inline = <Request extends MediaRequest, Response>(
input: InlineInput<Request, Response>,
): Route<Request, Response> => {
): InlineRoute<Request, Response> => {
const transport = makeTransport(input)
return {
kind: "inline",
@@ -267,59 +267,6 @@ export const stream = <Request extends MediaRequest, Event, Response, Frame, Sta
}
}
export const dispatch = <Event, Response>(input: {
readonly modality: string
readonly execute: Execute
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
}) => {
const notQueued = (route: { readonly provider: ProviderID; readonly id: string }, operation: string) =>
new AIError({
reason: new UnsupportedOperationError({
operation: `${input.modality}.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
}),
})
const start = <Request extends MediaRequest>(route: AnyRoute<Request, Event, Response>, request: Request) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, input.execute)
}
return {
start,
resume: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
model: MediaRequest["model"],
token: unknown,
) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "resume"))
return route.resume(model, token, input.execute)
},
generate: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
) => {
if (route.kind !== "queued") return route.generate(request, input.execute)
return start(route, request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
): Stream.Stream<Event | Observation, AIError> => {
if (route.kind === "stream") return route.stream(request, input.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(route, request).pipe(
Effect.map((generation) => resultEvents(generation, input.responseEvents, options)),
),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, input.execute), input.responseEvents))
},
}
}
// ---------------------------------------------------------------------------
// Transport plumbing shared by every kind
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect"
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
import type { AIError, LanguageModelSanitizerCompatibility, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
/**
* The semantic API contract of one model server family.
@@ -43,6 +43,8 @@ export interface Protocol<Body, Frame, Event, State> {
readonly stream: ProtocolStream<Frame, Event, State>
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
/** Tool schema sanitizer for every model on this protocol unless the model's compatibility sets one; wrappers around another `body.from` must forward it. */
readonly sanitizer?: LanguageModelSanitizerCompatibility
}
export interface ProtocolBody<Body> {
+22 -44
View File
@@ -1,53 +1,31 @@
import { Context, Effect, Layer, Stream } from "effect"
import { RequestExecutor } from "./route/executor.js"
import type { AIError } from "./schema/index.js"
import type { SpeechEvent, SpeechOptions, SpeechRequestFor, SpeechResponse } from "./speech.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
SpeechTimestampsEvent,
SpeechFinishEvent,
type SpeechEvent,
type SpeechRequestFor,
type SpeechResponse,
} from "./speech.js"
export interface Interface {
readonly generate: <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
) => Effect.Effect<SpeechResponse, AIError>
readonly stream: <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
) => Stream.Stream<SpeechEvent, AIError>
}
export type Interface = MediaClient.Interface<SpeechRequestFor, SpeechEvent, SpeechResponse>
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
export const Service = SpeechClientService
export type Service = SpeechClientService
export const generate = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
): Effect.Effect<SpeechResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
})
export const stream = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
): Stream.Stream<SpeechEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request)
}),
)
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
stream: (request) => request.model.route.stream(request, executor.execute),
})
}),
)
export const SpeechClient = {
Service,
layer,
generate,
stream,
...MediaClient.make(Service, {
modality: "speech",
responseEvents: (response: SpeechResponse) => [
...(response.timestamps === undefined ? [] : [SpeechTimestampsEvent.make({ items: response.timestamps })]),
SpeechFinishEvent.make({
audio: response.audio,
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+31 -41
View File
@@ -1,8 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { ProgressEvent, QueuedEvent } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { SpeechClient, Service } from "./speech-client.js"
@@ -10,53 +10,39 @@ import { SpeechClient, Service } from "./speech-client.js"
// Model
// ---------------------------------------------------------------------------
export type SpeechOptions = Record<string, unknown>
export type SpeechOptions = MediaModel.Options
export type SpeechRoute<Options extends SpeechOptions = SpeechOptions> = MediaRoute.StreamRoute<
SpeechRequestFor<Options>,
SpeechEvent,
SpeechResponse
>
export type SpeechRoute = MediaRoute.AnyRoute<SpeechRequestFor, SpeechEvent, SpeechResponse>
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<
SpeechRoute<Options>,
Options
> {
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<SpeechRoute, Options> {
declare protected readonly _SpeechModel: void
static make<Options extends SpeechOptions = SpeechOptions>(input: MediaModel.Input<SpeechRoute<Options>>) {
return new SpeechModel<Options>(input)
}
/** Compose a streaming speech protocol with its canonical path into a model for one deployment. */
static fromRoute<Options extends SpeechOptions = SpeechOptions, Frame = unknown, State = unknown>(
route: SpeechModel.RouteInput<Options, Frame, State>,
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends SpeechOptions>(
route: MediaModel.InlineRouteInput<SpeechRequestFor<Options>, SpeechResponse>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Frame, State>(
route: MediaModel.StreamRouteInput<SpeechRequestFor<Options>, SpeechEvent, Frame, State>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Token>(
route: MediaModel.QueuedRouteInput<SpeechRequestFor<Options>, SpeechResponse, Token>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Frame, State, Token>(
route: MediaModel.AnyRouteInput<SpeechRequestFor<Options>, SpeechEvent, SpeechResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new SpeechModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(
(composition) => MediaRoute.stream({ ...composition, collect: collectResponse }),
route,
input,
),
route: composeRoute(route, input, collectResponse) as SpeechRoute,
})
}
}
export namespace SpeechModel {
export type RouteInput<
Options extends SpeechOptions = SpeechOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<SpeechRequestFor<Options>>,
MediaProtocol.Streamed<SpeechRequestFor<Options>, SpeechEvent, Frame, State>
>
}
export const SpeechModelSchema = Schema.declare((value): value is SpeechModel => value instanceof SpeechModel, {
expected: "Speech.Model",
})
@@ -148,11 +134,17 @@ export const SpeechFinishEvent = Schema.Struct({
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "Speech.Event.Finish" })
const speechEventTagged = Schema.Union([SpeechAudioDeltaEvent, SpeechTimestampsEvent, SpeechFinishEvent]).pipe(
Schema.toTaggedUnion("type"),
)
const speechEventTagged = Schema.Union([
QueuedEvent,
ProgressEvent,
SpeechAudioDeltaEvent,
SpeechTimestampsEvent,
SpeechFinishEvent,
]).pipe(Schema.toTaggedUnion("type"))
export const SpeechEvent = Object.assign(speechEventTagged, {
is: {
generationQueued: speechEventTagged.guards["generation-queued"],
generationProgress: speechEventTagged.guards["generation-progress"],
audioDelta: speechEventTagged.guards["audio-delta"],
timestamps: speechEventTagged.guards.timestamps,
finish: speechEventTagged.guards.finish,
@@ -195,17 +187,15 @@ export function request(input: SpeechRequest | SpeechRequestInput) {
const requestEffect = (input: SpeechRequest | SpeechRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends SpeechModel>(
input: SpeechRequestInput<Model>,
input: SpeechRequest | SpeechRequestInput<Model>,
): Effect.Effect<SpeechResponse, AIError, Service>
export function generate(input: SpeechRequest): Effect.Effect<SpeechResponse, AIError, Service>
export function generate(input: SpeechRequest | SpeechRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => SpeechClient.generate(request)))
}
export function stream<const Model extends SpeechModel>(
input: SpeechRequestInput<Model>,
input: SpeechRequest | SpeechRequestInput<Model>,
): Stream.Stream<SpeechEvent, AIError, Service>
export function stream(input: SpeechRequest): Stream.Stream<SpeechEvent, AIError, Service>
export function stream(input: SpeechRequest | SpeechRequestInput) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => SpeechClient.stream(request))))
}
+8 -85
View File
@@ -1,34 +1,13 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
TranscriptionFinishEvent,
type TranscriptionEvent,
type TranscriptionModel,
type TranscriptionOptions,
type TranscriptionRequestFor,
type TranscriptionResponse,
} from "./transcription.js"
export interface Interface {
readonly generate: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<TranscriptionResponse, AIError>
readonly stream: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<TranscriptionEvent, AIError>
readonly start: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
readonly resume: <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
}
export type Interface = MediaClient.Interface<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
"@opencode/TranscriptionClient",
@@ -36,66 +15,10 @@ export class TranscriptionClientService extends Context.Service<TranscriptionCli
export const Service = TranscriptionClientService
export type Service = TranscriptionClientService
export const generate = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const start = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<TranscriptionEvent, TranscriptionResponse>({
modality: "transcription",
execute: executor.execute,
responseEvents,
})
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
})
}),
)
export const TranscriptionClient = {
Service,
layer,
generate,
stream,
start,
resume,
...MediaClient.make(Service, {
modality: "transcription",
responseEvents: (response: TranscriptionResponse) => [TranscriptionFinishEvent.make({ ...response })],
}),
} as const
+21 -76
View File
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
import { TranscriptionClient, Service } from "./transcription-client.js"
@@ -11,90 +10,49 @@ import { TranscriptionClient, Service } from "./transcription-client.js"
// Model
// ---------------------------------------------------------------------------
export type TranscriptionOptions = Record<string, unknown>
export type TranscriptionOptions = MediaModel.Options
export type TranscriptionRoute<Options extends TranscriptionOptions = TranscriptionOptions> = MediaRoute.AnyRoute<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse
>
export type TranscriptionRoute = MediaRoute.AnyRoute<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
export class TranscriptionModel<Options extends TranscriptionOptions = TranscriptionOptions> extends MediaModel<
TranscriptionRoute<Options>,
TranscriptionRoute,
Options
> {
declare protected readonly _TranscriptionModel: void
static make<Options extends TranscriptionOptions = TranscriptionOptions>(
input: MediaModel.Input<TranscriptionRoute<Options>>,
) {
return new TranscriptionModel<Options>(input)
}
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends TranscriptionOptions>(
route: TranscriptionModel.InlineRouteInput<Options>,
route: MediaModel.InlineRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Frame, State>(
route: TranscriptionModel.StreamRouteInput<Options, Frame, State>,
route: MediaModel.StreamRouteInput<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Token>(
route: TranscriptionModel.QueuedRouteInput<Options, Token>,
route: MediaModel.QueuedRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Frame, State, Token>(
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
route: MediaModel.AnyRouteInput<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse,
Frame,
State,
Token
>,
input: MediaRoute.ModelInput,
) {
return new TranscriptionModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composeRoute(route, input, collectResponse) as TranscriptionRoute,
})
}
}
export namespace TranscriptionModel {
export type InlineRouteInput<Options extends TranscriptionOptions = TranscriptionOptions> = MediaModel.RouteInput<
TranscriptionRequestFor<Options>,
MediaProtocol.Inline<TranscriptionRequestFor<Options>, TranscriptionResponse>
>
export type StreamRouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<TranscriptionRequestFor<Options>>,
MediaProtocol.Streamed<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>
>
export type QueuedRouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Token = unknown,
> = MediaModel.RouteInput<
TranscriptionRequestFor<Options>,
MediaProtocol.Queued<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>
>
export type RouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse,
Frame,
State,
Token
>
}
export const TranscriptionModelSchema = Schema.declare(
(value): value is TranscriptionModel => value instanceof TranscriptionModel,
{ expected: "Transcription.Model" },
@@ -212,10 +170,6 @@ export const TranscriptionEvent = Object.assign(transcriptionEventTagged, {
})
export type TranscriptionEvent = Schema.Schema.Type<typeof transcriptionEventTagged>
export const responseEvents = (response: TranscriptionResponse): ReadonlyArray<TranscriptionEvent> => [
TranscriptionFinishEvent.make({ ...response }),
]
const collectResponse = (events: ReadonlyArray<TranscriptionEvent>): Effect.Effect<TranscriptionResponse> => {
const finish = events.find(TranscriptionEvent.is.finish)
// Every transcription protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
@@ -243,11 +197,7 @@ export function request(input: TranscriptionRequest | TranscriptionRequestInput)
const requestEffect = (input: TranscriptionRequest | TranscriptionRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service>
export function generate(
input: TranscriptionRequest,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service>
export function generate(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
@@ -255,11 +205,7 @@ export function generate(input: TranscriptionRequest | TranscriptionRequestInput
}
export function stream<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service>
export function stream(
input: TranscriptionRequest,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service>
export function stream(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
@@ -268,15 +214,14 @@ export function stream(input: TranscriptionRequest | TranscriptionRequestInput,
/** Inline and streaming routes fail with `UnsupportedOperation`. */
export function start<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
export function start(input: TranscriptionRequest): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
export function start(input: TranscriptionRequest | TranscriptionRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => TranscriptionClient.start(request)))
}
export const resume = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
export const resume = (
model: TranscriptionModel,
token: unknown,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> => TranscriptionClient.resume(model, token)
+16 -84
View File
@@ -1,98 +1,30 @@
import { Context, Effect, Layer, Stream } from "effect"
import { resultEvents, type AwaitOptions, type Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
VideoOutputEvent,
VideoFinishEvent,
type VideoEvent,
type VideoModel,
type VideoOptions,
type VideoRequestFor,
type VideoResponse,
} from "./video.js"
export interface Interface {
readonly start: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
) => Effect.Effect<Generation<VideoResponse>, AIError>
readonly resume: <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
) => Effect.Effect<Generation<VideoResponse>, AIError>
readonly generate: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<VideoResponse, AIError>
readonly stream: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<VideoEvent, AIError>
}
export type Interface = MediaClient.Interface<VideoRequestFor, VideoEvent, VideoResponse>
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
export const Service = VideoClientService
export type Service = VideoClientService
export const start = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const generate = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<VideoResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<VideoEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const start = <Options extends VideoOptions>(request: VideoRequestFor<Options>) =>
request.model.route.start(request, executor.execute)
return Service.of({
start,
resume: (model, token) => model.route.resume(model, token, executor.execute),
generate: (request, options) => start(request).pipe(Effect.flatMap((generation) => generation.await(options))),
stream: (request, options) =>
Stream.unwrap(
start(request).pipe(Effect.map((generation) => resultEvents(generation, responseEvents, options))),
),
})
}),
)
export const VideoClient = {
Service,
layer,
start,
resume,
generate,
stream,
...MediaClient.make(Service, {
modality: "video",
responseEvents: (response: VideoResponse) => [
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
VideoFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+37 -41
View File
@@ -3,7 +3,6 @@ import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./gen
import { Media } from "./media.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { VideoClient, Service } from "./video-client.js"
@@ -11,41 +10,39 @@ import { VideoClient, Service } from "./video-client.js"
// Model
// ---------------------------------------------------------------------------
export type VideoOptions = Record<string, unknown>
export type VideoOptions = MediaModel.Options
export type VideoRoute<Options extends VideoOptions = VideoOptions> = MediaRoute.QueuedRoute<
VideoRequestFor<Options>,
VideoResponse
>
export type VideoRoute = MediaRoute.AnyRoute<VideoRequestFor, VideoEvent, VideoResponse>
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute<Options>, Options> {
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute, Options> {
declare protected readonly _VideoModel: void
static make<Options extends VideoOptions = VideoOptions>(input: MediaModel.Input<VideoRoute<Options>>) {
return new VideoModel<Options>(input)
}
/** Compose a queued video protocol with its canonical start path into a model for one deployment. */
static fromRoute<Options extends VideoOptions = VideoOptions, Token = unknown>(
route: VideoModel.RouteInput<Options, Token>,
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends VideoOptions>(
route: MediaModel.InlineRouteInput<VideoRequestFor<Options>, VideoResponse>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Frame, State>(
route: MediaModel.StreamRouteInput<VideoRequestFor<Options>, VideoEvent, Frame, State>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Token>(
route: MediaModel.QueuedRouteInput<VideoRequestFor<Options>, VideoResponse, Token>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Frame, State, Token>(
route: MediaModel.AnyRouteInput<VideoRequestFor<Options>, VideoEvent, VideoResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new VideoModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(MediaRoute.queued, route, input),
route: composeRoute(route, input, collectResponse) as VideoRoute,
})
}
}
export namespace VideoModel {
export type RouteInput<Options extends VideoOptions = VideoOptions, Token = unknown> = MediaModel.RouteInput<
VideoRequestFor<Options>,
MediaProtocol.Queued<VideoRequestFor<Options>, VideoResponse, Token>
>
}
export const VideoModelSchema = Schema.declare((value): value is VideoModel => value instanceof VideoModel, {
expected: "Video.Model",
})
@@ -149,15 +146,19 @@ export const VideoEvent = Object.assign(videoEventTagged, {
})
export type VideoEvent = Schema.Schema.Type<typeof videoEventTagged>
/** A completed response expanded into the streaming event shape. */
export const responseEvents = (response: VideoResponse): ReadonlyArray<VideoEvent> => [
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
VideoFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
]
const collectResponse = (events: ReadonlyArray<VideoEvent>): Effect.Effect<VideoResponse> => {
const finish = events.find(VideoEvent.is.finish)
// A streaming video protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
if (finish === undefined) return Effect.die(new Error("The video stream completed without a finish event"))
return Effect.succeed(
new VideoResponse({
videos: events.filter(VideoEvent.is.video).map((event) => event.video),
usage: finish.usage,
notices: finish.notices,
providerMetadata: finish.providerMetadata,
}),
)
}
// ---------------------------------------------------------------------------
// Request-shaped call API
@@ -178,33 +179,28 @@ export function request(input: VideoRequest | VideoRequestInput) {
const requestEffect = (input: VideoRequest | VideoRequestInput) => tryRequest(() => request(input))
export function start<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
): Effect.Effect<Generation<VideoResponse>, AIError, Service>
export function start(input: VideoRequest): Effect.Effect<Generation<VideoResponse>, AIError, Service>
export function start(input: VideoRequest | VideoRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.start(request)))
}
export function generate<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<VideoResponse, AIError, Service>
export function generate(input: VideoRequest, options?: AwaitOptions): Effect.Effect<VideoResponse, AIError, Service>
export function generate(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.generate(request, options)))
}
/** Rebuild a generation handle from a persisted `Generation.token`, refreshing its status once. */
export const resume = <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> => VideoClient.resume(model, token)
export const resume = (model: VideoModel, token: unknown): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
VideoClient.resume(model, token)
export function stream<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<VideoEvent, AIError, Service>
export function stream(input: VideoRequest, options?: AwaitOptions): Stream.Stream<VideoEvent, AIError, Service>
export function stream(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => VideoClient.stream(request, options))))
}
+13
View File
@@ -54,6 +54,19 @@ import { TestLLM } from "@opencode/ai/testing"
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
describe("public exports", () => {
test("modality, provider, and protocol entrypoints load first in a fresh process", async () => {
const results = await Promise.all(
["image", "video", "speech", "transcription", "providers", "protocols"].map(async (entry) => {
const child = Bun.spawn(
[process.execPath, "-e", `await import(${JSON.stringify(`${import.meta.dir}/../src/${entry}.ts`)})`],
{ stderr: "pipe" },
)
return { entry, exitCode: await child.exited, stderr: await new Response(child.stderr).text() }
}),
)
expect(results.filter((result) => result.exitCode !== 0)).toEqual([])
})
test("root exposes app-facing runtime APIs", () => {
expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction()
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50}"
},
{
"direction": "server",
@@ -525,7 +525,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
},
{
"direction": "server",
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -109,7 +109,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -109,7 +109,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
},
{
"direction": "server",
@@ -119,7 +119,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -44,7 +44,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":40,\"stream\":true}"
},
"response": {
"status": 200,
@@ -24,7 +24,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -25,7 +25,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
@@ -43,7 +43,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
+3 -3
View File
@@ -7,7 +7,6 @@ import {
type ImageModelOptions,
type ImageOptions,
type ImageRequestFor,
type ImageRoute,
} from "../src/index.js"
import type { Service } from "../src/image-client.js"
import { Anthropic, BlackForestLabs, Google, OpenAI, Stability, XAI, ZAI } from "../src/providers.js"
@@ -21,8 +20,7 @@ type GoogleLikeOptions = {
readonly thinkingLevel?: "LOW" | "HIGH"
} & Record<string, unknown>
declare const route: ImageRoute<GoogleLikeOptions>
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
declare const google: ImageModel<GoogleLikeOptions>
// @ts-expect-error Extracted model options retain known provider fields.
const invalidGoogleOptions: ImageModelOptions<typeof google> = { imageSize: "8K" }
void invalidGoogleOptions
@@ -152,6 +150,8 @@ Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality:
Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { userID: 1 } })
declare const generic: ImageModel<ImageOptions>
const widenImage = <Options extends ImageOptions>(model: ImageModel<Options>): ImageModel => model
void widenImage
Image.generate({ model: generic, prompt: "A lighthouse", providerOptions: { arbitrary: true } })
const explicitAsset: Media.Asset = Media.url("https://example.com/image.png")
void explicitAsset
@@ -6,6 +6,7 @@ import {
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src/index.js"
import { ai } from "../src/promise.js"
import { OpenAIChat } from "../src/protocols.js"
interface ExampleOptions {
@@ -31,6 +32,10 @@ const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
type InputGenerateRequirements = Assert<Equal<Requirements<typeof generatedFromInput>, LLMClientService>>
const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
type InputStreamRequirements = Assert<Equal<StreamRequirements<typeof streamedFromInput>, LLMClientService>>
LLM.request({
model,
@@ -39,6 +44,11 @@ LLM.request({
providerOptions: { mode: "slow" },
})
// @ts-expect-error Direct input keeps the selected model's provider option types.
LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
// @ts-expect-error Stream input keeps the selected model's provider option types.
LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
const generatedObject = LLM.generateObject({
model,
prompt: "Hello",
@@ -69,5 +79,16 @@ const options: LanguageModelProviderOptions<typeof model> = { mode: "fast" }
void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
void (true satisfies InputGenerateRequirements)
void (true satisfies InputStreamRequirements)
void (true satisfies GenerateObjectRequirements)
void (true satisfies GenerateDynamicObjectRequirements)
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
void ai.llm.generate(ai.llm.request({ model, prompt: "Hello" }))
void ai.llm.stream(ai.llm.request({ model, prompt: "Hello" }))
// @ts-expect-error Promise direct input keeps the selected model's provider option types.
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
// @ts-expect-error Promise stream input keeps the selected model's provider option types.
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
+25 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import { Effect, Schema, Stream } from "effect"
import { CacheHint, LLM, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
@@ -13,6 +14,8 @@ import {
ToolDefinition,
ToolResultPart,
} from "../src/schema/index.js"
import { fixedResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
@@ -240,6 +243,26 @@ describe("llm constructors", () => {
expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
})
test("generates and streams from input or a prebuilt request", async () => {
const model = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).chat("gpt-4o-mini")
const layer = fixedResponse(
sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }),
)
const input = { model, prompt: "Say hello." }
const request = LLM.request(input)
const generated = await Effect.runPromise(LLM.generate(input).pipe(Effect.provide(layer)))
const generatedFromRequest = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(layer)))
expect(generated.text).toBe("Hello")
expect(generatedFromRequest.text).toBe(generated.text)
const streamed = await Effect.runPromise(LLM.stream(input).pipe(Stream.runCollect, Effect.provide(layer)))
const streamedFromRequest = await Effect.runPromise(
LLM.stream(request).pipe(Stream.runCollect, Effect.provide(layer)),
)
expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true)
expect(streamedFromRequest).toEqual(streamed)
})
test("extracts output text from response events", () => {
expect(
LLMResponse.text({
+27 -6
View File
@@ -116,19 +116,32 @@ describe("AI promise client", () => {
const seen: Array<string> = []
const ai = AI.make({ layer: executor(seen) })
const text = await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
const request = ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
const text = await ai.llm.generate(request)
expect(text.text).toBe("Hello world")
expect((await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe(
"Hello world",
)
const image = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
expect(image.image).toBeInstanceOf(Media.Asset)
expect(image.image.mediaType).toBe("image/png")
expect(await ai.run(image.image.bytes())).toEqual(Uint8Array.from([1, 2, 3]))
const requested = await ai.image.generate(
ai.image.request({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }),
)
expect(requested.image.mediaType).toBe("image/png")
const deltas: Array<string> = []
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
for await (const event of ai.llm.stream(request)) {
if (LLMEvent.is.textDelta(event)) deltas.push(event.text)
}
expect(deltas).toEqual(["Hello", " world"])
const directDeltas: Array<string> = []
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
if (LLMEvent.is.textDelta(event)) directDeltas.push(event.text)
}
expect(directDeltas).toEqual(deltas)
const imageEvents: Array<string> = []
for await (const event of ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })) {
@@ -137,8 +150,11 @@ describe("AI promise client", () => {
expect(imageEvents).toEqual(["image-partial", "image", "finish"])
expect(seen).toEqual([
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/images/generations",
"https://openai.test/v1/images/generations",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/images/generations",
])
@@ -260,22 +276,27 @@ describe("AI promise client", () => {
const ai = AI.make({ layer: executor([]) })
const failure = await ai.llm
.generate({ model: openai.responses("gpt-5"), prompt: "Hello" })
.generate(ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" }))
.then(() => undefined)
.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AIError)
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
const invalid = await ai.llm
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
const invalidLLM = await ai.llm
// @ts-expect-error Invalid input must reject with AIError instead of throwing synchronously.
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
.catch((error: unknown) => error)
expect(invalidLLM instanceof AIError && invalidLLM.reason._tag).toBe("InvalidRequest")
const invalid = await ai.image
.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse", n: 1.5 })
.catch((error: unknown) => error)
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
const controller = new AbortController()
controller.abort()
const aborted = await ai.llm
.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }, { signal: controller.signal })
.generate(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal })
.then(() => "completed")
.catch(() => "aborted")
expect(aborted).toBe("aborted")
+27
View File
@@ -216,6 +216,33 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s
}),
)
it.effect("Alibaba fits explicit thinking budgets to half the output limit", () =>
Effect.gen(function* () {
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
const chat = (maxTokens?: number) =>
compileRequest(
LLM.request({
model: provider.chat("qwen3.7-plus"),
prompt: "hi",
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
providerOptions: { enableThinking: true, thinkingBudget: 131_071 },
}),
).pipe(Effect.map((prepared) => prepared.body.thinking_budget))
const messages = yield* compileRequest(
LLM.request({
model: provider.messages("qwen3.7-plus"),
prompt: "hi",
generation: { maxTokens: 32_000 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 131_071 } },
}),
)
expect(yield* chat(32_000)).toBe(16_000)
expect(yield* chat()).toBe(131_071)
expect(messages.body.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 })
}),
)
it.effect("Alibaba validates malformed options before execution", () =>
Effect.gen(function* () {
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
@@ -148,11 +148,13 @@ describe("Anthropic Messages route", () => {
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 4_096 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 1_024 } },
}),
)
const legacy = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 4_096 },
providerOptions: { thinking: { type: "enabled", budget_tokens: 2_048 } },
}),
)
@@ -168,6 +170,22 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("fits the thinking budget to half the output limit", () =>
Effect.gen(function* () {
const thinking = (maxTokens: number) =>
compileRequest(
LLMRequest.update(request, {
generation: { maxTokens },
providerOptions: { thinking: { type: "enabled", budgetTokens: 31_999 } },
}),
).pipe(Effect.map((prepared) => prepared.body.thinking))
expect(yield* thinking(64_000)).toEqual({ type: "enabled", budget_tokens: 31_999 })
expect(yield* thinking(20_000)).toEqual({ type: "enabled", budget_tokens: 10_000 })
expect(yield* thinking(1_500)).toEqual({ type: "enabled", budget_tokens: 1_024 })
}),
)
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -244,6 +244,29 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("fits a Claude thinking budget below maxTokens", () =>
Effect.gen(function* () {
const fields = (maxTokens: number, budgetTokens: number, topK?: number) =>
compileRequest(
LLMRequest.update(baseRequest, {
model: AmazonBedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0", {
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
thinking: { type: "enabled", budgetTokens },
}),
generation: GenerationOptions.make({ maxTokens, topK }),
}),
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
expect(yield* fields(64_000, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 31_999 } })
expect(yield* fields(20_000, 31_999, 40)).toEqual({
top_k: 40,
thinking: { type: "enabled", budget_tokens: 10_000 },
})
expect(yield* fields(1_500, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 1_024 } })
}),
)
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
+81 -10
View File
@@ -22,21 +22,22 @@ testEffect(
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
store: true,
instructions: "Keep the context",
parallel_tool_calls: true,
parallel_tool_calls: false,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
context_management: [{ type: "compaction" }],
max_tool_calls: 1,
tool_choice: "required",
text: { verbosity: "high", format: { type: "json_object" } },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
@@ -57,7 +58,7 @@ testEffect(
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
).effect("trigger keeps request controls, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const input = LLM.request({
@@ -67,12 +68,14 @@ testEffect(
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
parallelToolCalls: false,
maxToolCalls: 1,
textVerbosity: "low",
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
@@ -82,8 +85,7 @@ testEffect(
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
text: { verbosity: "high", format: { type: "json_object" } },
tool_choice: "required",
},
},
@@ -114,6 +116,75 @@ testEffect(
}),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text).text).toEqual({ verbosity: "low", format: { type: "json_object" } })
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps explicit verbosity on a trigger checkpoint for prompt cache reuse", () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
providerOptions: { textVerbosity: "low" },
http: { body: { text: { format: { type: "json_object" } } } },
}),
trigger,
),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.text).toEqual({ verbosity: "high", format: { type: "json_object" } })
expect(body.max_output_tokens).toBe(20_000)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps the effective body-overlay verbosity and text formatting", () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
generation: { maxTokens: 20_000 },
providerOptions: { textVerbosity: "low" },
http: { body: { text: { verbosity: "high", format: { type: "json_object" } } } },
}),
trigger,
),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text).max_output_tokens).toBe(128)
return respond(JSON.stringify({ error: { message: "max_output_tokens must be at least 20000" } }), {
status: 400,
headers: { "content-type": "application/json" },
})
}),
),
).effect("passes configured output limits through and leaves rejection to the provider", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
generation: { maxTokens: 128 },
}),
trigger,
).pipe(Effect.flip)
expect(error.message).toContain("at least 20000")
}),
)
const idless = { type: "compaction", encrypted_content: "opaque" }
testEffect(
fixedResponse(
@@ -184,7 +255,7 @@ testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: ch
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { stream: false }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
@@ -110,11 +110,16 @@ for (const model of [
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
expect(JSON.parse(text)).toEqual({
model: "fixture",
input: [item],
instructions: "Keep the context",
include: ["reasoning.encrypted_content"],
})
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
).effect(`${model.provider} validates tools but ignores unrelated unsupported generation settings`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
@@ -151,6 +156,11 @@ for (const model of [
] as const) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe(tag)
if (candidate.tools.length > 0) {
const compactError = yield* LLMClient.compact(candidate).pipe(Effect.flip)
expect(compactError.reason._tag).toBe("InvalidRequest")
continue
}
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
@@ -255,6 +265,13 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
model: "fixture",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }],
service_tier: overlay?.service_tier ?? "flex",
reasoning: { effort: "low" },
text: { verbosity: "low", format: { type: "json_object" } },
include: ["reasoning.encrypted_content"],
parallel_tool_calls: false,
tools: [
{ type: "function", name: "lookup", description: "Lookup", parameters: { type: "object" }, strict: false },
],
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
@@ -268,12 +285,20 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
prompt: "hello",
promptCacheKey: "affinity",
providerOptions: { serviceTier: "flex" },
providerOptions: {
serviceTier: "flex",
reasoningEffort: "low",
textVerbosity: "low",
include: ["reasoning.encrypted_content"],
parallelToolCalls: false,
},
generation: { maxTokens: 100 },
tools: [{ name: "lookup", description: "Lookup", inputSchema: {} }],
http: {
body: {
stream: true,
store: false,
text: { format: { type: "json_object" } },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
...overlay,
@@ -396,6 +421,8 @@ for (const model of [
model: model.id,
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "original" }] }],
instructions: "system",
include: ["reasoning.encrypted_content"],
...(model.id === "gpt-5.3-codex" ? { reasoning: { effort: "medium", summary: "auto" } } : {}),
})
return respond(
JSON.stringify({
@@ -407,7 +434,10 @@ for (const model of [
)
}
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
expect(body.input).toEqual([...output, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }])
expect(body.input).toEqual([
...output,
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
headers: { "content-type": "text/event-stream" },
})
+17
View File
@@ -90,6 +90,23 @@ describe("Gemini route", () => {
}),
)
it.effect("fits the thinking budget to half the output limit", () =>
Effect.gen(function* () {
const thinkingBudget = (budget: number, maxTokens = 32_000) =>
compileRequest(
LLMRequest.update(request, {
generation: { maxTokens },
providerOptions: { thinkingConfig: { thinkingBudget: budget } },
}),
).pipe(Effect.map((prepared) => prepared.body.generationConfig?.thinkingConfig?.thinkingBudget))
expect(yield* thinkingBudget(32_768)).toBe(16_000)
expect(yield* thinkingBudget(8_000)).toBe(8_000)
expect(yield* thinkingBudget(-1)).toBe(-1)
expect(yield* thinkingBudget(8_192, 1_000)).toBe(512)
}),
)
it.effect("forwards standard Gemini generation options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,7 +1945,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.text).toBeUndefined()
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 })
@@ -152,6 +152,27 @@ describe("OpenRouter", () => {
}),
)
it.effect("fits the reasoning budget to half the output limit", () =>
Effect.gen(function* () {
const reasoning = (maxTokens: number | undefined, value: Record<string, unknown>) =>
compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3.8-flash"),
cache: "none",
prompt: "Hello",
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
providerOptions: { reasoning: value },
}),
).pipe(Effect.map((prepared) => prepared.body.reasoning))
expect(yield* reasoning(32_000, { max_tokens: 131_071 })).toEqual({ max_tokens: 16_000 })
expect(yield* reasoning(131_072, { max_tokens: 65_536 })).toEqual({ max_tokens: 65_536 })
expect(yield* reasoning(1_500, { max_tokens: 65_536 })).toEqual({ max_tokens: 1_024 })
expect(yield* reasoning(undefined, { max_tokens: 131_071 })).toEqual({ max_tokens: 131_071 })
expect(yield* reasoning(32_000, { effort: "high" })).toEqual({ effort: "high" })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+39
View File
@@ -28,10 +28,49 @@ const cartesia = Cartesia.configure({ apiKey: "test", baseURL: "https://cartesia
const google = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
"gemini-2.5-flash-preview-tts",
)
const google38 = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
"gemini-3.8-flash-tts",
)
const google38Lite = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
"gemini-3.8-flash-lite-tts",
)
const deepgram = Deepgram.configure({ apiKey: "test", baseURL: "https://deepgram.test" }).speech("aura-2-thalia-en")
const voice = "JBFqnCBsd6RMkjVDRZzb"
describe("Speech", () => {
it.effect("preserves Google's WAV output instead of describing it as raw PCM", () =>
Effect.gen(function* () {
const bytes = new TextEncoder().encode("RIFF....WAVEfmt ")
const response = yield* Speech.generate({ model: google38, text: "Hi" }).pipe(
Effect.provide(
respond(
JSON.stringify({
candidates: [
{ content: { parts: [{ inlineData: { mimeType: "audio/wav", data: Encoding.encodeBase64(bytes) } }] } },
],
}),
"application/json",
),
),
)
expect(response.audio.mediaType).toBe("audio/wav")
expect(response.audio.info?.format).toBe("wav")
expect(response.audio.info?.encoding).toBeUndefined()
expect(yield* response.audio.bytes()).toEqual(bytes)
}),
)
it.effect("rejects raw PCM for Gemini 3.8 unary requests before sending", () =>
Effect.gen(function* () {
const errors = yield* Effect.all(
[google38, google38Lite].map((model) =>
Speech.generate({ model, text: "Hi", format: "pcm" }).pipe(Effect.flip),
),
).pipe(Effect.provide(layer(() => Effect.die("An unsupported request reached the network"))))
expect(errors.map((error) => error.reason._tag)).toEqual(["UnsupportedOperation", "UnsupportedOperation"])
}),
)
it.effect("rejects what a provider cannot produce before sending anything", () =>
Effect.gen(function* () {
const errors = yield* Effect.all(
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Stream } from "effect"
import { Speech, type SpeechEvent } from "../src/index.js"
import { Speech, SpeechModel, type SpeechEvent, type SpeechOptions } from "../src/index.js"
import { ElevenLabs, OpenAI, Runway } from "../src/providers.js"
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
@@ -7,6 +7,8 @@ type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
const elevenlabs = ElevenLabs.configure({ apiKey: "test" }).speech("eleven_flash_v2_5")
const widenSpeech = <Options extends SpeechOptions>(model: SpeechModel<Options>): SpeechModel => model
void widenSpeech
Speech.generate({
model: elevenlabs,
+11 -1
View File
@@ -1,5 +1,11 @@
import type { Stream } from "effect"
import { Media, Transcription, type TranscriptionEvent } from "../src/index.js"
import {
Media,
Transcription,
TranscriptionModel,
type TranscriptionEvent,
type TranscriptionOptions,
} from "../src/index.js"
import { AssemblyAI, Deepgram, OpenAI } from "../src/providers.js"
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
@@ -8,6 +14,10 @@ type Assert<T extends true> = T
const audio = Media.url("https://example.com/call.mp3")
const deepgram = Deepgram.configure({ apiKey: "test" }).transcription("nova-3")
const widenTranscription = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
): TranscriptionModel => model
void widenTranscription
Transcription.generate({
model: deepgram,
+3 -3
View File
@@ -9,7 +9,6 @@ import {
type VideoModelOptions,
type VideoOptions,
type VideoRequestFor,
type VideoRoute,
} from "../src/index.js"
import type { Service } from "../src/video-client.js"
import { Anthropic, Fal, Google, OpenAI, Runway, XAI } from "../src/providers.js"
@@ -23,8 +22,7 @@ type VeoLikeOptions = {
readonly personGeneration?: "allow_all" | "allow_adult"
} & Record<string, unknown>
declare const route: VideoRoute<VeoLikeOptions>
const veo = VideoModel.make<VeoLikeOptions>({ id: "veo", provider: "google", route })
declare const veo: VideoModel<VeoLikeOptions>
// @ts-expect-error Extracted model options retain known provider fields.
const invalidVeoOptions: VideoModelOptions<typeof veo> = { personGeneration: "everyone" }
void invalidVeoOptions
@@ -99,6 +97,8 @@ Video.generate({ model: google, prompt: "A kitten", durationSeconds: "8s" })
Video.generate({ model: google, prompt: "A kitten", options: { personGeneration: "allow_all" } })
declare const generic: VideoModel<VideoOptions>
const widenVideo = <Options extends VideoOptions>(model: VideoModel<Options>): VideoModel => model
void widenVideo
Video.generate({ model: generic, prompt: "A kitten", providerOptions: { arbitrary: true } })
const request = Video.request({ model: veo, prompt: "A kitten", providerOptions: { personGeneration: "allow_all" } })
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
covered: false,
captures: 0,
holdCapture: false,
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
capture: async () => {
setStore("captures", (count) => count + 1)
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
const canvas = new OffscreenCanvas(4, 4)
const paint = canvas.getContext("2d")
if (paint) {
paint.fillStyle = "#3b82f6"
paint.fillRect(0, 0, 4, 4)
}
return canvas.convertToBlob()
},
close: () => undefined,
},
]),
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<p>Captures: {store.captures}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
<div
data-popper-positioner
data-testid="fixture-popover"
style={{
position: "fixed",
top: "0",
left: "0",
width: "320px",
height: "480px",
"z-index": "1001",
"pointer-events": "none",
}}
/>
</Portal>
</Show>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const still = root.locator("#browser-panel img")
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
// The native page stays up until its still is ready, so the pane never shows blank.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await root.getByRole("button", { name: "Release capture", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(still).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("covered.png") })
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,7 +14,12 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: "steer" | "queue"
}
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -234,6 +239,108 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.log[0]).toBe("prompt:queue")
})
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const row = view.rows.filter({ hasText: "second queued prompt" })
const actions = row.locator('[data-slot="session-queue-actions"] button')
await expect(actions).toHaveCount(3)
expect(
await actions.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
),
).toEqual(["Steer", "Undo", "Remove"])
const undo = row.getByRole("button", { name: "Undo" })
await expect(undo).toHaveText("")
await expect(undo.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-down-to-line")
await undo.hover()
await expect(page.getByRole("tooltip")).toHaveText("Undo")
await undo.click()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"third queued prompt",
])
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
expect(mock.prompts).toEqual([])
})
test("Undo appends to an existing draft and restores inline attachments", async ({ page }) => {
const mock = createQueueMock(["queued with image"])
mock.rows[0].payload.files = [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
mime: "image/png",
source: { type: "inline" },
name: "shot.png",
},
]
const view = await openSession(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("my draft\n\nqueued with image")
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(7)
const row = view.rows.filter({ hasText: text })
await row.getByRole("button", { name: "Undo" }).hover()
await expect(page.getByRole("tooltip")).toHaveText("Undo")
await page.screenshot({ path: testInfo.outputPath("undo-narrow-queue.png") })
await row.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(6)
await expect(view.input).toHaveText(text)
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
]
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Undo does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
+1
View File
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
undoing: Accessor<boolean>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
@@ -168,6 +168,7 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
undoing: () => false,
cancelEdit() {},
editFirst: () => false,
}
+2
View File
@@ -16,6 +16,7 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ export function Composer(props: {
<ComposerEditor
controller={props.model}
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
+1
View File
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.undoing()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
@@ -86,6 +86,7 @@ export function createHomeSessionsController(home: HomeController) {
sessions: indexedSessions,
projectDirectories,
projects: home.project.list,
resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session),
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
@@ -291,6 +292,7 @@ export function createHomeSessionsController(home: HomeController) {
sessions: () => [result],
projectDirectories,
projects: home.project.list,
resolveProject: ctx.projects.forSession,
})[0]
},
create: home.project.openNewSession,
+7 -5
View File
@@ -18,6 +18,7 @@ export function buildHomeSessionRecords(input: {
sessions: () => SessionInfo[]
projectDirectories: () => string[] | undefined
projects: () => LocalProject[]
resolveProject?: (session: SessionInfo) => LocalProject | undefined
}) {
const selected = input.projectDirectories()
const directories = selected ? new Set(selected.map(pathKey)) : undefined
@@ -27,11 +28,12 @@ export function buildHomeSessionRecords(input: {
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const project = homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
const project = input.resolveProject?.(session) ??
homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
}
return { session, project, projectName: displayName(project) }
})
}
+12
View File
@@ -874,6 +874,9 @@ export const dict = {
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.undo": "Undo",
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
@@ -1439,6 +1442,15 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.account.manage": "Manage {{provider}} accounts",
"settings.providers.account.group": "Accounts",
"settings.providers.account.add": "Add account",
"settings.providers.account.remove": "Remove account…",
"settings.providers.account.active": "Active",
"settings.providers.account.switched.title": "{{provider}} account switched",
"settings.providers.account.switched.description": "Now using {{account}}.",
"settings.providers.account.removed.title": "{{account}} removed",
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
close(): void
}
@@ -18,6 +18,8 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
import type { SessionInfo } from "@opencode/client/promise"
import { resolveProjectForSession, resolveSessionDetailsProject } from "@/shell/layout/helpers"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -180,6 +182,13 @@ function createServerController(
}
const projectsList = createMemo(() => projects.list().map(enrich))
const forSession = (session: SessionInfo) => {
const project = resolveProjectForSession(session, projectsList(), sync.data.project)
if (!project) return
return "expanded" in project ? project : { ...project, expanded: false }
}
const detailsForSession = (session: SessionInfo) =>
resolveSessionDetailsProject(session, projectsList(), sync.data.project)
const recentlyClosedList = createMemo(() => {
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
return projects
@@ -200,6 +209,8 @@ function createServerController(
projects: {
...projects,
list: projectsList,
forSession,
detailsForSession,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
@@ -43,6 +43,9 @@ function fixture() {
async command(command) {
call.commands.push(command)
},
async capture() {
return null
},
close() {
call.closed = true
},
+63 -2
View File
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
let frame: number | undefined
let layout: string | undefined
let until = 0
let capturing: Browser.TabID | undefined
let release: ReturnType<typeof setTimeout> | undefined
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
setStore("snapshot", next)
}
// Keep the page on screen as a still under the floating content. The native view
// stays visible until the still has decoded, so the pane never flashes blank.
const freeze = (tabID: Browser.TabID) => {
clearTimeout(release)
release = undefined
if (store.snapshot?.tabID === tabID || capturing === tabID) return
capturing = tabID
void (registration()?.capture(tabID) ?? Promise.resolve(null))
.catch(() => null)
.then(async (blob) => {
const url = blob ? URL.createObjectURL(blob) : ""
if (url) {
const image = new Image()
image.src = url
await image.decode().catch(() => undefined)
}
if (capturing !== tabID) {
if (url) URL.revokeObjectURL(url)
return
}
capturing = undefined
// A failed capture still hides the page; the pane shows its background as before.
replaceSnapshot({ tabID, url })
schedule()
})
}
const thaw = () => {
capturing = undefined
if (!store.snapshot || release !== undefined) return
// Keep the still under the native view until the view has painted again.
release = setTimeout(() => {
release = undefined
replaceSnapshot()
}, 150)
}
const measure = () => {
if (!surface) return
const tab = state()
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
clearTimeout(release)
capturing = undefined
replaceSnapshot()
})
return (
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
{(url) => (
<img
src={url()}
alt=""
draggable={false}
class="absolute inset-0 size-full pointer-events-none select-none"
/>
)}
</Show>
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
@@ -175,6 +175,18 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
<Tooltip placement="top" value={language.t("session.queue.undo")}>
<IconButton
data-action="session-queue-undo"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="arrow-down-to-line" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.undo")}
onClick={() => props.queue.undo(props.id)}
/>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptUndoDraft, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
expect(queuedPromptAttachments(item)).toEqual([])
})
})
describe("queuedPromptUndoDraft", () => {
test("keeps full text, structured mentions, and inline images", () => {
const item = {
...queued[0],
payload: {
text: "inspect @main.ts with @build",
files: [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
],
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptUndoDraft(item)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
{ type: "text", content: " with " },
{ type: "agent", content: "@build", name: "build" },
{ type: "image", filename: "shot.png" },
])
})
test("does not drop hidden file context", () => {
const item = {
...queued[0],
payload: {
text: "inspect this",
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptUndoDraft(item)).toBeUndefined()
})
})
+115 -3
View File
@@ -3,10 +3,11 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -54,6 +56,16 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "undo") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
const draft = input.draft.current()
const prompt = promptLength(draft)
? appendPrompt(draft, change.prompt)
: [...change.prompt, ...draft.filter(isAttachment)]
input.draft.set(prompt, promptLength(prompt))
input.restoreFocus(promptLength(prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -139,6 +151,21 @@ export function createSessionQueue(input: {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const undo = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (input.draft.mode.current() !== "normal") {
showToast({ title: language.t("session.queue.undoShell") })
return
}
const prompt = queuedPromptUndoDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.undoUnavailable") })
return
}
mutation.mutate({ type: "undo", item, prompt })
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
@@ -226,9 +253,11 @@ export function createSessionQueue(input: {
editFirst,
rows,
busy: () => mutation.isPending,
undoing: () => mutation.isPending && mutation.variables?.type === "undo",
working: input.working,
steer,
remove,
undo,
edit,
reorder,
}
@@ -239,7 +268,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "undo" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -249,7 +278,8 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -287,6 +317,88 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
export function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return !file.mention && file.source.type === "inline"
}
+6 -1
View File
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
+1 -2
View File
@@ -14,7 +14,6 @@ import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
@@ -66,7 +65,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
return info ? server.ctx.projects.detailsForSession(info) : undefined
})
const isDesktop = session.isDesktop
const btw = createSessionBtw(session)
@@ -12,7 +12,7 @@ import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { displayName, errorMessage, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettingsSurface } from "@/settings/surface"
@@ -319,11 +319,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
if (props.session) return server.ctx.projects.forSession(props.session)
const projects = server.ctx.projects.list()
if (props.session)
return (
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
)
const value = directory()
if (!value) return undefined
const key = pathKey(value)
+2 -3
View File
@@ -1,7 +1,7 @@
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { getFilename } from "@opencode/util/path"
import { createMemo, Show, type JSX } from "solid-js"
import { createMemo, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
@@ -16,7 +16,6 @@ export function SessionSummaryPanel(props: {
shown?: boolean
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
@@ -44,7 +43,7 @@ export function SessionSummaryPanel(props: {
return (
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
<div>
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
<ProjectSummaryCard project={props.project}>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
@@ -1,16 +1,14 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
import { createUniqueId, Show, type ParentProps } from "solid-js"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import "./summary.css"
export function ProjectSummaryCard(
props: ParentProps<{
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
avatar?: JSX.Element
}>,
) {
const settings = useSettings()
@@ -26,13 +24,7 @@ export function ProjectSummaryCard(
aria-controls={contentID}
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<ProjectIcon project={props.project} />
<span dir="auto" class="session-summary-label">
{displayName(props.project)}
</span>
@@ -31,7 +31,6 @@ import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
@@ -143,14 +142,14 @@ function MessageTimelineView(
const session = props.session.data.info()
const projects = server.ctx.sync.data.project
return session
? projectForSession(session, projects)
? server.ctx.projects.detailsForSession(session)
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const avatarProject = createMemo(() => {
const headerProject = createMemo(() => {
const session = props.session.data.info()
if (!session) return
return projectForSession(session, server.ctx.projects.list()) ?? project()
return server.ctx.projects.forSession(session)
})
createEffect(() => {
const directory = project()?.worktree
@@ -408,7 +407,7 @@ function MessageTimelineView(
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
<SessionProjectMenu
project={avatarProject()}
project={headerProject()}
directory={sessionDirectory()}
workspace={workspaceSession()}
/>
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode/client/promise"
import { activeProviderAccount, providerAccounts } from "./accounts"
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
id: "openai",
name: "OpenAI",
methods: [],
connections,
})
describe("provider accounts", () => {
test("preserves the server's active-first credential order", () => {
const value = integration([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "env", name: "OPENAI_API_KEY" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(providerAccounts(value)).toEqual([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
})
test("returns no active account for environment-only integrations", () => {
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
expect(providerAccounts(value)).toEqual([])
expect(activeProviderAccount(value)).toBeUndefined()
expect(providerAccounts(undefined)).toEqual([])
})
})
@@ -0,0 +1,11 @@
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
export function providerAccounts(integration: IntegrationInfo | undefined) {
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
}
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
return providerAccounts(integration)[0]
}
+162 -19
View File
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { SettingsList } from "@/settings/list"
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
import "@/settings/settings.css"
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
consoleExpanded: false,
connecting: false,
credentialID: undefined as string | undefined,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
setState("disconnecting", (current) => ({
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
return currentSource !== "env" && currentSource !== "config"
}
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (item: ProviderItem, name: string) => {
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
})
}
const refreshAccounts = async () => {
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
])
}
const accountError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
if (activeProviderAccount(integration(provider))?.id === account.id) return
setState("credentialID", account.id)
await serverSdk.api.credential
.activate({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
description: language.t("settings.providers.account.switched.description", { account: account.label }),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
const final = providerAccounts(integration(provider)).length === 1
setState("credentialID", account.id)
await serverSdk.api.credential
.remove({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t(
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
final ? { provider: providerName } : { account: account.label },
),
description: language.t(
final
? "provider.disconnect.toast.disconnected.description"
: "settings.providers.account.removed.description",
{ provider: providerName },
),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
const accounts = () => providerAccounts(integration(menuProps.provider))
const active = () => activeProviderAccount(integration(menuProps.provider))
const name = () => menuProps.name ?? menuProps.provider.name
return (
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={Button}
size="normal"
variant="ghost-muted"
class="settings-provider-account-trigger"
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
>
<span>{active()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.Group>
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
<Menu.RadioGroup
class="settings-provider-account-list"
value={active()?.id}
onChange={(credentialID) => {
const account = accounts().find((item) => item.id === credentialID)
if (account) void activate(menuProps.provider, name(), account)
}}
>
<For each={accounts()}>
{(account) => (
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
{language.t("settings.providers.account.add")}
</Menu.Item>
<Menu.Sub placement="left-start">
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
{language.t("settings.providers.account.remove")}
</Menu.SubTrigger>
<Menu.SubContent class="settings-provider-account-submenu">
<For each={accounts()}>
{(account) => (
<Menu.Item
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
onSelect={() => void remove(menuProps.provider, name(), account)}
>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.Item>
)}
</For>
</Menu.SubContent>
</Menu.Sub>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
return (
<>
<div class="settings-tab-header">
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
</div>
</div>
<Show
when={canDisconnect(item)}
when={canManageAccounts(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() =>
void disconnect(item, item.name)
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} />
</Show>
</div>
}
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
<Show
when={canManageAccounts(item)}
fallback={
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
>
{language.t("common.disconnect")}
</Button>
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
</Show>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
+41
View File
@@ -887,6 +887,47 @@
opacity: 1;
}
.settings-provider-account-trigger {
min-width: 0;
max-width: min(240px, 45%);
}
.settings-provider-account-trigger > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-provider-account-menu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
overflow: visible;
}
.settings-provider-account-list {
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
overflow-y: auto;
}
.settings-provider-account-submenu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
max-height: min(360px, var(--kb-popper-content-available-height));
overflow-y: auto;
}
.settings-provider-account-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container settings-panel (max-width: 520px) {
.settings-provider-account-trigger {
max-width: 100%;
}
}
.settings-providers-view-all {
margin-top: 20px;
padding: 0;
+2 -5
View File
@@ -11,7 +11,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { displayName, resolveProjectForSession } from "@/shell/layout/helpers"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/runtime/server/current"
@@ -253,9 +253,7 @@ export function createServerSessionEntries(props: {
})
if (current.signal.aborted) return []
const opened = props.opened()
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return Promise.all([
props.load(search, current.signal).then(
(result) => result.data,
@@ -271,8 +269,7 @@ export function createServerSessionEntries(props: {
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
const project = resolveProjectForSession(session, opened, stored)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
+63 -4
View File
@@ -50,6 +50,17 @@ export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID:
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) || project.worktree
type ProjectAppearance = {
name?: string
worktree: string
icon?: { color?: string; url?: string; override?: string }
}
function withProjectAppearance<T extends ProjectAppearance>(metadata: T, appearance?: ProjectAppearance) {
if (!appearance || appearance === metadata) return metadata
return { ...metadata, name: displayName(appearance), icon: appearance.icon }
}
export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined,
server: ServerConnection.Key,
@@ -97,11 +108,59 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: SessionInfo,
projects: T[],
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
) {
const direct = byID.get(session.projectID)
if (direct) return direct
return projects.find((project) => isProjectDirectory(project, session.location.directory))
const matching = projects.filter((project) => project.id === session.projectID)
if (matching.length === 1) return matching[0]
if (matching.length > 1) {
const directory = pathKey(session.location.directory)
const exact =
matching.find((project) => pathKey(project.worktree) === directory) ??
matching.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
if (exact) return exact
return (
matching
.filter((project) => isProjectDirectory(project, session.location.directory))
.sort((a, b) => b.worktree.length - a.worktree.length)[0] ?? matching.at(-1)
)
}
const directory = pathKey(session.location.directory)
const exact =
projects.find((project) => pathKey(project.worktree) === directory) ??
projects.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
if (exact) return exact
return projects
.filter((project) => isProjectDirectory(project, session.location.directory))
.sort((a, b) => b.worktree.length - a.worktree.length)[0]
}
export function resolveProjectForSession<
T extends { id?: string; worktree: string; sandboxes?: string[] },
U extends { id?: string; worktree: string; sandboxes?: string[] },
>(session: SessionInfo, opened: T[], stored: U[]) {
const current = projectForSession(session, opened)
if (current?.id === session.projectID) {
const unresolved = opened.find(
(project) => !project.id && pathKey(project.worktree) === pathKey(session.location.directory),
)
if (!unresolved) return current
const canonical = projectForSession(session, stored)
if (canonical?.id === session.projectID && pathKey(canonical.worktree) === pathKey(unresolved.worktree))
return unresolved
return current
}
const synced = projectForSession(session, stored)
if (synced?.id !== session.projectID) return current ?? synced
if (current && !current.id && pathKey(current.worktree) === pathKey(session.location.directory)) return current
return synced
}
export function resolveSessionDetailsProject<
T extends ProjectAppearance & { id?: string; sandboxes?: string[] },
U extends ProjectAppearance & { id?: string; sandboxes?: string[] },
>(session: SessionInfo, opened: T[], stored: U[]) {
const metadata = projectForSession(session, stored)
if (!metadata) return
return withProjectAppearance(metadata, resolveProjectForSession(session, opened, stored))
}
export const errorMessage = (err: unknown, fallback: string) => {
@@ -4,7 +4,7 @@ import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
project: LocalProject
project: Pick<LocalProject, "id" | "name" | "worktree" | "icon">
fallback?: string
icon?: LocalProject["icon"]
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { Menu } from "@opencode/ui/menu"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { displayName } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode/client/promise"
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
const project = createMemo(() => {
const session = props.session
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
return serverCtx()?.projects.forSession(session)
})
const title = createMemo(() => {
const session = props.session
+1 -2
View File
@@ -29,7 +29,6 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
import { updaterAction } from "@/shell/updates/action"
import type { UpdaterState } from "@/shell/updates/types"
@@ -441,7 +440,7 @@ export function Titlebar(props: {
const value = session()
if (!tab || !value) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
return conn ? global.ensureServerCtx(conn).projects.forSession(value) : undefined
})
const currentTitle = () => {
const tab = currentTab()
+1 -1
View File
@@ -21,7 +21,7 @@
"build:node": "bun run script/build-node.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo -b"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
+2 -1
View File
@@ -7,5 +7,6 @@
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
"exclude": ["dist", "dist-node"],
"references": [{ "path": "../core" }]
}

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