Compare commits

..

276 Commits

Author SHA1 Message Date
opencode-agent[bot] 1c9e4663df chore: update nix node_modules hashes 2026-08-24 03:51:33 +00:00
usrnk1 3f807d8836 feat(desktop): add home session actions (#43245)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 13:18:03 +10:00
Aiden Cline 4876f416cc fix(ai): fail loudly on missing anthropic tool_use id (#44569) 2026-08-23 21:56:39 -05:00
Luke Parker dbc178c3cc fix: add DOM.AsyncIterable lib so ReadableStream iteration typechecks (#44580) 2026-08-24 02:26:54 +00:00
opencode-agent[bot] 73974dc668 chore: update nix node_modules hashes 2026-08-24 02:18:31 +00:00
Kit Langton 1593479454 chore(core): upgrade drizzle orm to rc5 (#44573) 2026-08-23 21:56:02 -04:00
Kit Langton 971681c567 fix(image): degrade photon resizer load failure to typed error on workerd (#43894) 2026-08-23 21:44:01 -04:00
Luke Parker 378a7b7be1 fix: stream Bun runtime downloads to disk to avoid Bun.write GC hang (#44572) 2026-08-24 11:22:42 +10:00
Aiden Cline 6d57667168 fix(ai): clamp prompt_cache_key to 64 chars for all chat protocols (#44571) 2026-08-23 20:21:15 -05:00
Aiden Cline d4935360c5 fix(ai): keep stateless hosted tool results and tolerate WS keepalives (#44488) 2026-08-23 20:19:14 -05:00
Aiden Cline 89281dcd01 fix(ai): preserve display on enabled thinking (#44544) 2026-08-23 20:05:30 -05:00
Aiden Cline 9f9184b002 fix(ai): preserve OpenAI tool schemas (#44546) 2026-08-23 20:05:06 -05:00
Kit Langton c6f4fa0d1f fix(core): never build fff index for workspace locations (#44563) 2026-08-24 01:00:27 +00:00
Kit Langton c505c91438 fix(codemode): package conditional transpilers (#44565) 2026-08-23 20:50:39 -04:00
Kit Langton 8887c2ffa9 fix(core): skip host realpath canonicalization for workspace locations (#44564) 2026-08-23 20:50:29 -04:00
opencode-agent[bot] ddaf9ff433 chore: update nix node_modules hashes 2026-08-24 00:35:01 +00:00
usrnk1 d3e5d6d268 feat(desktop): add model change notices (#43288)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-08-24 00:17:35 +00:00
Kit Langton f9bc948912 fix(core): skip local liveness check for workspace locations (#44560) 2026-08-23 20:17:04 -04:00
opencode-agent[bot] 6f238feeff chore: update nix node_modules hashes 2026-08-24 00:07:29 +00:00
Kit Langton e867a21ea5 feat(core): decouple workspace identity from provisioning (#44526) 2026-08-23 23:49:51 +00:00
Luke Parker 64c0411edb fix(session-ui): align tool error card with figma (#44543) 2026-08-23 23:31:45 +00:00
opencode-agent[bot] 7da10aa65d fix(ai): retry detail-free response errors (#44537)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 17:39:55 -05:00
Luke Parker d80b0a1e7e fix(desktop): reconnect to elected service (#44369) 2026-08-24 08:27:04 +10:00
opencode-agent[bot] 481125f617 chore: update nix node_modules hashes 2026-08-23 22:15:43 +00:00
Kit Langton 59d0d64a6b chore: upgrade Effect to rc.111 (#44518) 2026-08-23 18:01:44 -04:00
opencode-agent[bot] e0cdf1ed09 fix(tui): restore global prompt history (#44416)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-23 17:53:16 -04:00
Kit Langton 9e50d76416 fix(codegen): write prettier-stable schema snapshot (#43985) 2026-08-23 17:34:47 -04:00
Dax 771c0f5850 fix(core): avoid stale MCP OAuth snapshots destroying rotated credentials (#44527) 2026-08-23 17:02:51 -04:00
Kit Langton b1a0ef91bb refactor(core): use FiberMap for title generation (#44519) 2026-08-23 16:22:43 -04:00
Kit Langton 2e67cee75b fix(tui): recover from terminal forms (#44516) 2026-08-23 16:21:28 -04:00
opencode-agent[bot] 890735c1d7 feat(plugin): expose tool input schema (#44510)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-23 14:19:57 -05:00
Aiden Cline 049f0b0c3b fix(ai): handle additional usage locations and tool history for OpenAI Chat (#44502) 2026-08-23 13:48:30 -05:00
Aiden Cline 6020f36862 fix(ai): handle anthropic error events and ping frames (#44500) 2026-08-23 13:45:21 -05:00
Simon Klee 0d24ebdbbe tui: preserve selection after copy (#44496)
Copy-on-select reset the click counter, so a third click could not select the full line. Keep the selection after copying to preserve multi-click input.
2026-08-23 20:40:19 +02:00
opencode-agent[bot] b22c182406 fix(ai): preserve inline file data URLs (#44499)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-23 13:39:38 -05:00
Aiden Cline c7f2f367e3 refactor(ai): remove deprecated local shell (#44497) 2026-08-23 13:36:10 -05:00
Aiden Cline ccd2135e7a fix(ai): tolerate explicit nulls in Gemini stream payloads (#44490) 2026-08-23 13:17:13 -05:00
Brendan Allan c7c22b9d7e fix(app): stabilize pane transitions (#44484) 2026-08-23 17:59:00 +00:00
Aiden Cline 67e99993f5 fix(ai): align Responses compatibility (#44482) 2026-08-23 12:53:14 -05:00
Brendan Allan be6abc02b5 fix(app): preserve session panes across navigation (#44477) 2026-08-23 17:37:24 +00:00
Aiden Cline e012a57d1d fix(ai): harden Gemini tool call id handling on Vertex and duplicate streams (#44454) 2026-08-23 12:31:30 -05:00
Brendan Allan 697e3e13cc fix(app): simplify session max width (#44468) 2026-08-24 01:01:11 +08:00
Brendan Allan 89451c3e32 fix(app): navigate parent with escape (#44441) 2026-08-23 23:45:29 +08:00
Brendan Allan ad7d515d50 fix(session-ui): avoid replaying thinking summary animation (#44440) 2026-08-23 23:43:17 +08:00
Brendan Allan 8e71c8425f fix(app): hide empty terminal cursor (#44435) 2026-08-23 15:34:40 +00:00
Brendan Allan da4ab56801 fix(session-ui): hide empty file diffs (#44425) 2026-08-23 23:15:47 +08:00
Brendan Allan dd780ca882 fix(app): hand off optimistic attachments (#44411) 2026-08-23 22:59:51 +08:00
Brendan Allan fb9c9a2cbd fix(desktop): restore session completion alerts (#44412) 2026-08-23 22:35:06 +08:00
Brendan Allan 4c786f0ff8 fix(app): preserve subagent tab lineage (#44404) 2026-08-23 22:18:39 +08:00
Brendan Allan 1def4aa35a fix(app): avoid replaying streamed markdown fade (#44396) 2026-08-23 21:10:43 +08:00
opencode-agent[bot] de11122c96 feat(app): search sessions by ID (#44387)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 20:17:57 +08:00
opencode-agent[bot] 62e3b6ad78 chore: update nix node_modules hashes 2026-08-23 11:49:19 +00:00
Brendan Allan 2c99b549db fix(app): redesign model selection dialog (#44383) 2026-08-23 19:34:14 +08:00
Luke Parker d7c3a95368 feat(app): refine expanded shell output (#44368) 2026-08-23 11:31:40 +00:00
opencode-agent[bot] 3b5b5aee0c chore: update nix node_modules hashes 2026-08-23 11:16:11 +00:00
Brendan Allan 8cbfac3184 fix(desktop): smooth startup hydration (#44377) 2026-08-23 19:07:10 +08:00
opencode-agent[bot] e765349865 feat(session-ui): render streaming markdown with Solid (#44354)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-23 19:00:46 +08:00
opencode-agent[bot] 750f3384ff fix(app): defer service worker takeover (#44374)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 18:52:45 +08:00
Simon Klee 1cf61593b5 ci: fix node build (#44349) 2026-08-23 11:26:26 +02:00
opencode-agent[bot] 708e4d8867 fix(app): refine mobile error page (#44353) 2026-08-23 09:23:10 +00:00
Luke Parker 0a5349c51c fix(session-ui): restore compaction summaries (#44311) 2026-08-23 18:57:06 +10:00
opencode-agent[bot] 3fd93b659b chore: update nix node_modules hashes 2026-08-23 08:28:38 +00:00
Simon Klee 17a445de3e tui: update OpenTUI to 0.5.7 (#44345) 2026-08-23 10:15:17 +02:00
Brendan Allan 68e3888bc3 fix(app): preserve session header while loading (#44346) 2026-08-23 16:09:13 +08:00
Brendan Allan 27a53969d6 fix(app): stabilize initial timeline rendering (#44333) 2026-08-23 16:06:50 +08:00
Brendan Allan 1e3d3fcaca fix(app): complete leading paginated turns (#44322) 2026-08-23 15:27:33 +08:00
opencode-agent[bot] 7420903859 chore: update nix node_modules hashes 2026-08-23 07:25:41 +00:00
Simon Klee 853d3534e5 cli: share app archive across Node builds (#44331) 2026-08-23 09:11:54 +02:00
Brendan Allan 6cd3da1d4c fix(app): preserve scroll across history prepends (#44317) 2026-08-23 15:00:29 +08:00
Brendan Allan 9692bcbc21 fix(session-ui): align status tool typography (#44330) 2026-08-23 14:55:53 +08:00
Brendan Allan 79d5359d11 fix(app): align background hint spacing (#44329) 2026-08-23 14:50:32 +08:00
Brendan Allan b9ca77c5ff fix(app): suspend composer until restored (#44320) 2026-08-23 14:46:28 +08:00
Brendan Allan 7a72e51710 fix(app): reuse global model state (#44319) 2026-08-23 14:26:53 +08:00
Brendan Allan 0fda7d1b33 fix(session-ui): reduce tool error spacing (#44318) 2026-08-23 14:25:48 +08:00
Brendan Allan 6ba707d305 fix(app): synchronize composer model controls (#44316) 2026-08-23 14:25:27 +08:00
Brendan Allan d144c81d17 fix(app): preserve session shell while loading (#44315) 2026-08-23 14:18:38 +08:00
Brendan Allan b8fb894ec7 fix(server): compress responses with correct content types (#44321) 2026-08-23 06:18:20 +00:00
Brendan Allan b7167aaab0 feat(app): add project icon setting (#44323) 2026-08-23 06:18:15 +00:00
Luke Parker ad59ccc2e2 fix(ui): standardize safe text metrics (#44106) 2026-08-23 13:47:17 +10:00
Luke Parker a82318019f fix(app): skip service worker in development (#44302) 2026-08-23 13:35:05 +10:00
Luke Parker e9d58abd99 test(app): stabilize file expansion anchor (#44293) 2026-08-23 13:05:31 +10:00
Brendan Allan 1ce7e90d3e fix(app): register service worker after load (#44294) 2026-08-23 10:50:56 +08:00
Aiden Cline 92658e4389 fix(ai): preserve raw provider error payload on responses streams (#44271) 2026-08-22 20:59:59 -05:00
Dax 358a53cb1f fix(core): skip models.dev refresh event when the catalog is unchanged (#44282) 2026-08-22 21:03:02 -04:00
Dax Raad 54e2eef182 ci: use bun 1.4.0 instead of canary for publish builds 2026-08-22 20:44:26 -04:00
Dax 282b644cde fix(core): expire locations from session activity (#44275) 2026-08-22 20:21:37 -04:00
opencode-agent[bot] 4fee1bcf3c fix(tui): preserve rollback-compatible tab state (#44277) 2026-08-23 00:16:03 +00:00
opencode-agent[bot] da850f18da chore: update nix node_modules hashes 2026-08-23 00:05:17 +00:00
Dax 9a716609c6 fix(www): redirect root to docs (#44276) 2026-08-22 20:01:18 -04:00
Dax 917051a881 feat(www): rebuild site with Astro (#44274) 2026-08-22 19:49:47 -04:00
opencode-agent[bot] 55f490b898 fix(tui): flatten autocomplete descriptions (#44261) 2026-08-23 05:15:35 +05:30
Aiden Cline b190b778d4 fix(tui): avoid premature environment sync (#44270) 2026-08-22 18:39:04 -05:00
opencode-agent[bot] 95235df4c8 refactor(ai): accept nullable response phases (#44235)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-08-22 17:30:31 -05:00
opencode-agent[bot] c3f21fb5fb fix(ai): normalize Responses file inputs (#44234)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-22 16:45:18 -05:00
Dax Raad f282a22d98 feat(www): bold doc headings and header nav links 2026-08-22 16:48:52 -04:00
Dax Raad a859d48b08 fix(www): use OpenCode favicon instead of blume placeholder 2026-08-22 16:48:51 -04:00
Dax Raad 1f60ad6e2f feat(www): use OpenTUI Mono font for docs 2026-08-22 16:37:04 -04:00
Brendan Allan bdec7a4f87 fix(app): lighten background move hint (#44217) 2026-08-23 01:25:02 +08:00
opencode-agent[bot] d35f81ef92 chore: update nix node_modules hashes 2026-08-22 17:03:24 +00:00
Brendan Allan d0d0ada4a9 feat(app): cache mobile web startup assets (#44205) 2026-08-22 16:46:20 +00:00
opencode-agent[bot] beb6216343 fix(ui): constrain focus trap to top dialog (#44188)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-22 23:37:37 +08:00
Brendan Allan d9c9c50809 fix(app): pin background hint to timeline spacer (#44187) 2026-08-22 23:31:30 +08:00
opencode-agent[bot] a7106ae023 fix(app): clear terminal line with command delete (#44183)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-22 14:56:14 +00:00
Brendan Allan 2ae3b82c24 feat(app): add bottom terminal panel (#44175) 2026-08-22 22:52:26 +08:00
opencode-agent[bot] 784042202e chore: update nix node_modules hashes 2026-08-22 14:25:38 +00:00
opencode-agent[bot] 320da4256d fix(app): preserve home during server switch (#44178)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-22 22:22:55 +08:00
Brendan Allan 2bbaeb0d4b fix(app): simplify timeline anchoring (#44147) 2026-08-22 14:09:00 +00:00
Brendan Allan ada4f1bee6 feat(app): merge repeated file edits (#44107) 2026-08-22 21:49:09 +08:00
opencode-agent[bot] 75efc9d833 fix(tui): seed moved worktree location (#44143)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-22 09:07:27 -04:00
Brendan Allan 93e1f383dd fix(app): allow selecting diff text (#44140) 2026-08-22 20:34:47 +08:00
opencode-agent[bot] e4924ebf64 feat(app): add copy project ID command (#44126)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-22 19:57:47 +08:00
opencode-agent[bot] 992446e85d feat(app): add copy session ID command (#44125)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-22 19:52:04 +08:00
Brendan Allan b94ba27f00 feat(app): fade the background move hint (#44109) 2026-08-22 19:50:27 +08:00
Brendan Allan 8a2966fdbe fix(app): avoid duplicate retry errors (#44124) 2026-08-22 19:49:22 +08:00
Brendan Allan 4aca1be945 fix(app): prevent duplicate web mounts (#44119) 2026-08-22 19:38:25 +08:00
Luke Parker 100f23ca99 feat(app): show writing command state (#44095) 2026-08-22 20:21:33 +10:00
Brendan Allan 8f54597ef2 fix(app): collapse failed patch cards (#44105) 2026-08-22 18:05:23 +08:00
OpeOginni 85f32fa0da fix(app): load workspace sessions by directory (#44027)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-22 07:50:08 +00:00
Luke Parker 8062b5455a feat(app): align moved location notice (#44085) 2026-08-22 17:30:42 +10:00
Luke Parker 4566395d42 feat(app): show subagent delegation state (#44078) 2026-08-22 17:03:32 +10:00
opencode-agent[bot] 3c70f6df28 fix(app): enforce single titlebar action owner (#44075)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 17:03:24 +10:00
Brendan Allan 97536add75 fix(app): preserve bottom scroll anchoring (#44077) 2026-08-22 14:53:01 +08:00
Brendan Allan 6258c1943a feat(app): group adjacent patch calls (#43901) 2026-08-22 06:51:53 +00:00
Luke Parker 0d2c865c93 feat(app): move background work to session summary (#44068) 2026-08-22 16:31:53 +10:00
Luke Parker 7032a096bf fix(desktop): configure app before instance lock (#44067) 2026-08-22 16:09:13 +10:00
Luke Parker 80ef4f454f feat(desktop): align loaded resource calls with figma (#44060) 2026-08-22 15:17:11 +10:00
Luke Parker aa2dd5040f fix(desktop): omit shared rail from framed disclosures (#44061) 2026-08-22 15:17:09 +10:00
Luke Parker 9b17449e88 fix(desktop): match instructions notice typography to figma (#44059) 2026-08-22 15:06:13 +10:00
Luke Parker 346689fb43 fix(storybook): use production styles and remove synthetic tool stories (#44058) 2026-08-22 15:05:51 +10:00
Luke Parker 87d2488c78 feat(desktop): apply left border rail styling to all tool disclosures (#44052) 2026-08-22 14:23:18 +10:00
Aiden Cline a1d0f43531 fix(ai): preserve unique Gemini function call IDs (#44051)
Signed-off-by: Major Hayden <major@mhtx.net>
Co-authored-by: Major Hayden <major@mhtx.net>
2026-08-21 23:12:04 -05:00
Luke Parker 030b2d9543 feat(desktop): align instructions updated notice presentation with figma (#44050) 2026-08-22 13:49:24 +10:00
Luke Parker e7177a8764 fix(desktop): align web search results presentation with figma (#44039) 2026-08-22 13:22:22 +10:00
opencode-agent[bot] a84b1c15ce fix(app): wait for session route id (#44048)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 13:22:10 +10:00
opencode-agent[bot] d3eee25ee2 fix(session-ui): scope context row keys (#44043)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 02:57:53 +00:00
Luke Parker b4fabf5984 fix(desktop): align webfetch link styling with figma (#44037) 2026-08-22 02:29:03 +00:00
Kit Langton e33d688428 fix(codegen): stabilize generated contract names (#44000) 2026-08-22 00:16:27 +00:00
Kit Langton f69f78ec6b refactor(core): inline portable shell scanner (#44026) 2026-08-22 00:06:03 +00:00
opencode-agent[bot] e2758b9945 chore: update nix node_modules hashes 2026-08-21 23:53:08 +00:00
Kit Langton 7c6ecaaca8 fix(core): inherit fork instruction entries (#44004) 2026-08-21 19:46:49 -04:00
Kit Langton c29a7c152d feat(session): add viewed state (#42811) 2026-08-21 19:34:35 -04:00
Kit Langton fa4c5b26dc fix(core): stabilize forked message IDs (#44011) 2026-08-21 19:28:51 -04:00
Kit Langton fb703ede73 fix(core): transfer only settled history (#44008) 2026-08-21 19:28:04 -04:00
Kit Langton 2937f0e635 fix(core): omit running shells from forks (#44001) 2026-08-21 19:27:13 -04:00
Kit Langton 3694149135 fix(core): resolve device verification URL (#44021) 2026-08-21 23:26:40 +00:00
Kit Langton c4eeefe0f1 refactor: reuse undefined effect (#44010) 2026-08-21 19:17:29 -04:00
Kit Langton 667c274c7f fix(tui): contain MCP sidebar errors (#44003) 2026-08-21 23:00:06 +00:00
Kit Langton 2c8e2a2b28 fix(core): retry recreated locations (#44005) 2026-08-21 22:50:38 +00:00
opencode-agent[bot] 9e9eac81e3 chore: update nix node_modules hashes 2026-08-21 21:55:24 +00:00
Kit Langton 59f0e3a378 fix(core): omit active assistants from forks (#43994) 2026-08-21 21:52:36 +00:00
Dax Raad 656c4cc7fa fix(sdk): declare protocol test dependency 2026-08-21 17:38:51 -04:00
Dax Raad f28c10aa4d feat(sdk): prepare v2 packages for publishing 2026-08-21 17:28:34 -04:00
opencode-agent[bot] 97ccafce3b fix(core): honor wildcard gitignore rules in FFF (#43988)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-21 16:14:29 -05:00
Kit Langton 1864bc4161 fix(tui): preserve model label during location boot (#43974) 2026-08-21 17:06:43 -04:00
Aiden Cline 87ef814190 fix(ai): preserve thought signatures on visible text parts (#43984) 2026-08-21 15:28:43 -05:00
Kit Langton e945ddf80e chore: enforce effect simplifications (#43979) 2026-08-21 19:48:27 +00:00
Kit Langton fa1b4ef7ec fix(tui): scope prompt history by session (#43977) 2026-08-21 19:46:10 +00:00
Kit Langton 97d3cd0b3a refactor: simplify server client helpers (#43976) 2026-08-21 19:37:19 +00:00
Kit Langton 7fd1eee35a refactor(app): remove redundant UI state (#43975) 2026-08-21 19:30:00 +00:00
Kit Langton 2eecf076c4 refactor(config): share entry observer (#43973) 2026-08-21 19:29:08 +00:00
Kit Langton ed08f0e691 refactor(tui): simplify selection helpers (#43972) 2026-08-21 19:28:17 +00:00
Kit Langton 238e1903df refactor(session): tighten persisted state (#43969) 2026-08-21 19:27:21 +00:00
Kit Langton 77c7a7def7 refactor(stats): share unique-users fallback (#43971) 2026-08-21 19:26:43 +00:00
Kit Langton 88788941df refactor: simplify cloud package state (#43970) 2026-08-21 19:25:45 +00:00
Kit Langton d633d794c2 refactor(core): simplify domain helpers (#43968) 2026-08-21 19:24:56 +00:00
Kit Langton 08d52be8c2 refactor(core): simplify tool infrastructure (#43967) 2026-08-21 19:24:06 +00:00
Kit Langton 79d5436d2a refactor(session): simplify model preparation (#43966) 2026-08-21 19:21:04 +00:00
Kit Langton 9a4bd2ba16 refactor(core): simplify tool and shell code (#43948) 2026-08-21 18:45:05 +00:00
Kit Langton e68144cb67 refactor(core): fold single-consumer request modules into model-request (#43958) 2026-08-21 18:44:24 +00:00
Kit Langton e5da5bfab2 refactor(session): simplify effect workflows (#43956) 2026-08-21 18:41:06 +00:00
Kit Langton 3a1fb5ae65 refactor(session): simplify projection state (#43957) 2026-08-21 18:40:53 +00:00
Kit Langton b3d6063329 refactor(core): simplify persistence runtime code (#43954) 2026-08-21 18:40:25 +00:00
Kit Langton 6c3c4bc50f refactor(core): simplify mcp utility flows (#43955) 2026-08-21 18:40:03 +00:00
Kit Langton 7b349654e3 refactor(core): simplify provider runtime code (#43952) 2026-08-21 18:37:20 +00:00
Kit Langton 3d2652d7b9 refactor(core): simplify vcs state helpers (#43953) 2026-08-21 18:35:51 +00:00
Kit Langton 1dea4b9391 refactor(config): simplify utility workflows (#43947) 2026-08-21 18:34:19 +00:00
Kit Langton 15864304a5 refactor(core): clarify guards and constants (#43946) 2026-08-21 18:32:27 +00:00
Kit Langton e312d261a8 refactor(session): simplify history projection (#43944) 2026-08-21 18:25:47 +00:00
Kit Langton 2a83911c7e refactor(plugin): simplify websearch effects (#43943) 2026-08-21 18:23:10 +00:00
Kit Langton 0eaa04718c refactor(config): clarify discovery workflows (#43942) 2026-08-21 18:22:46 +00:00
Kit Langton 2e5ec616d2 feat(tui): optimistic session creation on first prompt (#43687) 2026-08-21 14:21:31 -04:00
opencode-agent[bot] b2551b4e5d fix(tui): stop tab marquee after one cycle (#43936)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-21 14:10:54 -04:00
Aiden Cline e81450809d fix(ai): gemini legacy tool media (#43920) 2026-08-21 12:51:26 -05:00
Kit Langton 2524e6be8b refactor(core): simplify persistence boundaries (#43929) 2026-08-21 17:48:27 +00:00
Kit Langton b58f29a4ef refactor(core): simplify plugin and config boundaries (#43930) 2026-08-21 17:48:17 +00:00
Kit Langton 8fec7e0e91 refactor(session): simplify execution and state workflows (#43933) 2026-08-21 17:48:02 +00:00
Kit Langton 94f9d32040 fix(core): re-inject nested instructions after compaction (#43723) 2026-08-21 13:13:24 -04:00
opencode-agent[bot] ea3e0dde19 feat(desktop): restore CLI installer menu item (#43906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-21 15:49:55 +00:00
opencode-agent[bot] b731b11184 fix(core): recover unknown finish responses (#43900)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-21 11:18:58 -04:00
Shoubhit Dash 8676dcf705 feat(ai): enable azure responses websocket (#43896) 2026-08-21 20:25:11 +05:30
Shoubhit Dash 2636797c65 feat(ai): enable xai responses websocket (#43889) 2026-08-21 19:58:01 +05:30
opencode-agent[bot] e673807e39 fix(app): hide built-in plugins (#43890)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-21 22:18:48 +08:00
Shoubhit Dash 9a3a1732f1 refactor(ai): declare responses websocket capability (#43885) 2026-08-21 19:24:34 +05:30
opencode-agent[bot] e03a147b71 test(app): route e2e mocks through HttpApi (#43138)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-21 21:41:43 +08:00
opencode-agent[bot] e461fdc2d0 chore: update nix node_modules hashes 2026-08-21 13:40:43 +00:00
Shoubhit Dash e4178886fa fix(core): align websocket network policy (#43875) 2026-08-21 18:49:57 +05:30
Filip 1e6bfaf3d7 feat: /skills command (#43869) 2026-08-21 14:31:17 +02:00
opencode-agent[bot] 876a4a2586 fix(stats): merge renamed model data (#43812)
Co-authored-by: fwang <83515+fwang@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-08-21 08:48:12 +00:00
Brendan Allan 5e77c494c7 fix(app): show folder names in file tree (#43835) 2026-08-21 16:40:07 +08:00
Brendan Allan 9be9dd737c fix(app): restore project menu spacing (#43810) 2026-08-21 15:59:32 +08:00
Brendan Allan 4d22d4e75f fix(desktop): use Effect platform subpath imports (#43817) 2026-08-21 15:32:55 +08:00
Aiden Cline 8b93bc395d feat(core): allow plan mode to write/edit PLAN files exclusively (#43710) 2026-08-21 02:29:50 -05:00
Aiden Cline e756e497c2 fix(ai): merge parallel gemini tool results into one turn (#43814) 2026-08-21 02:27:40 -05:00
Aiden Cline 0d2684b673 fix(ai): align Vertex Anthropic version headers (#43804) 2026-08-21 01:49:30 -05:00
opencode-agent[bot] 858caa6848 fix(ai): classify network error variants (#43807)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
2026-08-21 01:47:34 -05:00
Brendan Allan 9a89851cea fix(app): preserve timeline bottom after session switch (#43801) 2026-08-21 06:32:25 +00:00
Brendan Allan 876459788f feat(app): warn about V1 servers (#43790) 2026-08-21 06:30:58 +00:00
Aiden Cline b0ab1e2992 fix(core): forward PDF prompt attachments (#43799) 2026-08-21 01:16:05 -05:00
Aiden Cline d158f2cd39 fix(ai): scrub Anthropic tool call IDs (#43796) 2026-08-21 00:54:31 -05:00
Luke Parker 9be3aa92b5 refactor(app): organize product slices (#43789) 2026-08-21 15:48:04 +10:00
Aiden Cline d19f58c5df fix(core): use small model for titles (#43702) 2026-08-21 00:28:27 -05:00
opencode-agent[bot] b7343edaf3 fix(core): skip equal snapshot comparisons (#43781)
Co-authored-by: Aiden <rekram1-node@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-20 23:54:39 -05:00
Aiden Cline b84f5ad2fb fix(core): honor provider retry header (#43773) 2026-08-20 23:54:39 -05:00
Aiden Cline d993f1b8ed fix(ai): ignore unknown Anthropic SSE events (#43767) 2026-08-20 23:54:33 -05:00
Luke Parker f6fcbaad5e refactor(app): establish composer flow (#43768) 2026-08-21 14:23:14 +10:00
Aiden Cline 5c4f6ef1e3 fix(core): continue interrupted model streams (#43757) 2026-08-20 23:22:21 -05:00
Aiden Cline 212139ff95 fix(ai): accept nullable Anthropic input usage (#43761) 2026-08-20 22:27:16 -05:00
opencode-agent[bot] e5eabc446d chore: update nix node_modules hashes 2026-08-21 02:48:42 +00:00
opencode-agent[bot] ea43a16b7d refactor(core): remove legacy Gemini system prompt (#43752)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-20 21:48:06 -05:00
opencode-agent[bot] 960b1ca284 refactor(desktop): move IPC to Effect RPC (#43207)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-21 02:30:27 +00:00
Kit Langton 4db272ff64 fix(core): steer manual compaction by default (#43724) 2026-08-21 01:51:36 +00:00
opencode-agent[bot] fdc4fd8268 refactor(core): remove dead AI SDK ID stripping (#43741)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-20 20:44:53 -05:00
Luke Parker 67e87f534e fix(app): speed up cold home navigation (#43738) 2026-08-21 00:56:34 +00:00
Aiden Cline 2938ac3298 fix(core): prevent shell eviction loop (#43650) 2026-08-20 19:25:06 -05:00
opencode-agent[bot] 749d24ebc0 fix(client): authenticate PTY websocket connections (#43735)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-21 00:02:54 +00:00
Luke Parker 384cff3768 refactor(app): establish session vertical slice (#43637) 2026-08-21 09:16:07 +10:00
Aiden Cline 5970537a8a feat(plugin): expose session selection methods (#43718) 2026-08-20 18:04:43 -05:00
Simon Klee 58f909d5b9 chore: upgrade opentui 0.5.6 (#43725) 2026-08-20 22:13:12 +00:00
Dax cc15c2a488 feat(posts): add Astro posts site (#43720) 2026-08-20 16:37:28 -04:00
Kit Langton ebc2504ef3 refactor(core): route title and compaction through shared model requests (#43555) 2026-08-20 16:46:57 +00:00
Aiden Cline c33c9bf2b9 fix(core): preserve background shell status (#43663) 2026-08-20 11:01:15 -05:00
Kit Langton 2970b7a6a8 fix(core): settle foreign typed tool failures instead of dropping them (#43576) 2026-08-20 11:56:35 -04:00
Kit Langton 82d2c6133e fix(tui): stop registering one resize listener per transcript row (#43562) 2026-08-20 11:55:37 -04:00
Dax Raad 22f2604ffa fix(core): preserve unicode in compaction truncation 2026-08-20 11:52:42 -04:00
Shoubhit Dash a71884dfdf test(ai): record responses websocket flows (#43660) 2026-08-20 21:14:37 +05:30
Kit Langton 6f629c2a9d refactor(core): use Latch for shell output gate (#43573) 2026-08-20 11:38:01 -04:00
Kit Langton 4651bd15de refactor(server): use Latch for shutdown gate (#43571) 2026-08-20 11:37:35 -04:00
Kit Langton ad7ebe84a0 refactor(tui): use Latch for renderer shutdown gate (#43572) 2026-08-20 11:37:22 -04:00
Kit Langton 38eeed56cd refactor(core): use Latch for MCP startup gate (#43570) 2026-08-20 11:37:19 -04:00
Kit Langton b453c2016c refactor(core): use Latch for plugin supervisor ready gate (#43569) 2026-08-20 11:37:14 -04:00
opencode-agent[bot] cb25ed8cc8 fix(tui): restore option return newlines (#43655)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-20 10:19:36 -05:00
Filip 838d747514 fix(core): revert SSE heartbeat handling (#43625) 2026-08-20 13:23:08 +02:00
Filip 879766aee7 fix(core): ignore SSE comment heartbeats (#43618) 2026-08-20 12:59:58 +02:00
Jarred Sumner d6625397d9 tui: remove the win32 ENABLE_PROCESSED_INPUT ffi shim (#43604) 2026-08-20 09:04:40 +00:00
opencode-agent[bot] ab77fb080a chore: update nix node_modules hashes 2026-08-20 08:06:55 +00:00
Luke Parker 2a7d0729d0 feat(desktop): optimize cold development startup (#42722) 2026-08-20 17:40:50 +10:00
Aiden Cline e2d9376614 fix(ai): preserve response item ids (#43590) 2026-08-20 01:50:04 -05:00
Major Hayden f288d7e107 fix(ai): isolate Gemini function-response turns (#43479)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-20 01:31:36 -05:00
Dmitry Nefedov afe4c5d23a fix(tui): utilize TUI cursor style config in opencode2 mini (#43529) 2026-08-20 01:24:33 -05:00
Aiden Cline 98ad4465f8 fix(core): preserve discovered model limits (#43589) 2026-08-20 01:11:34 -05:00
Aiden Cline 5b1e8450e7 feat(ai): preserve streamed refusals as text (#43343) 2026-08-20 00:47:18 -05:00
Aiden Cline 99490f289b refactor(ai): remove model limits (#43581) 2026-08-20 00:36:35 -05:00
opencode-agent[bot] e37c7be434 fix(app): resolve branch from active directory (#43579)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-20 05:01:01 +00:00
Aiden Cline 39f4adb4dc fix(ai): settle pending Responses tool calls (#43575) 2026-08-19 23:53:27 -05:00
Dax Raad 393b43a881 Revert "feat(stats): combine free and go usage (#43577)"
This reverts commit 394b5ac5fd.
2026-08-20 00:49:46 -04:00
opencode-agent[bot] 394b5ac5fd feat(stats): combine free and go usage (#43577)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-20 00:43:44 -04:00
opencode-agent[bot] dbc7d0ee09 chore: update nix node_modules hashes 2026-08-20 04:01:43 +00:00
Aiden Cline 08f26a2d2e feat(ai): support Responses request options (#43360) 2026-08-19 22:45:26 -05:00
Kit Langton a51622a0e7 chore: upgrade Effect to rc.110 (#43567) 2026-08-20 03:43:23 +00:00
Kit Langton 20ff543ff2 fix(tui): honest reconnect overlay copy without a managed service (#43561) 2026-08-20 03:33:57 +00:00
Kit Langton f43474043a feat(core): acknowledge session interruption immediately (#43552) 2026-08-20 03:16:32 +00:00
Aiden Cline 5a0ba34d64 fix(core): expire stale shell output (#43554) 2026-08-19 21:58:58 -05:00
opencode-agent[bot] 9a1de86d9c refactor(core): own resolved model limits (#43545)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 21:40:33 -05:00
Kit Langton ea7fa43243 refactor(core): share session model requests (#42680) 2026-08-20 02:33:46 +00:00
opencode-agent[bot] 730e1935cf chore: update nix node_modules hashes 2026-08-20 02:33:37 +00:00
Luke Parker d6deed6752 refactor(session-ui): render current messages directly across surfaces (#43345) 2026-08-20 12:16:03 +10:00
Kit Langton 1d89e911e8 refactor(core): make prompt ID reuse idempotent (#43548) 2026-08-20 01:56:40 +00:00
opencode-agent[bot] c85b09de6f fix(core): default unknown model token limits (#43541)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 20:04:01 -05:00
Kit Langton 30db9dd86e chore(app): use schema ID minting instead of hand-rolled encoder (#43542) 2026-08-19 20:59:35 -04:00
Kit Langton b6966177fa refactor(core): simplify interrupt continuation (#42810) 2026-08-19 20:40:32 -04:00
Kit Langton 6b09b9e6a2 feat(client): optimistic prompt admission with client-minted IDs (#43520) 2026-08-19 20:34:12 -04:00
xdagiz 3876f7aad6 fix(desktop): show window on did-finish-load fallback for wayland (#42681) 2026-08-20 00:02:46 +00:00
Filip d912202cf2 feat: better skill ux (#43523) 2026-08-20 01:27:57 +02:00
Kit Langton f8c46684eb fix: eliminate flaky CI races (#43522) 2026-08-19 22:12:41 +00:00
Kit Langton c4afbc4aae fix(tui): handle form clipboard shortcut (#43526) 2026-08-19 18:10:44 -04:00
Dax 98a9d864e6 feat(plugin): add durable storage API (#43525) 2026-08-19 21:51:58 +00:00
1577 changed files with 66252 additions and 61538 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+37
View File
@@ -0,0 +1,37 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-1
View File
@@ -27,7 +27,6 @@ jobs:
working-directory: packages/www working-directory: packages/www
run: bun run build run: bun run build
env: env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }} CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy - name: Deploy
+37 -6
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
with: with:
bun-version: canary # Bun 1.4 until its stable release is published bun-version: 1.4.0
- name: Setup git committer - name: Setup git committer
id: committer id: committer
@@ -113,7 +113,7 @@ jobs:
id: build id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
BUN_COMPILE_RELEASE: canary BUN_COMPILE_RELEASE: bun-v1.4.0
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }} GH_REPO: ${{ needs.version.outputs.repo }}
@@ -195,9 +195,33 @@ jobs:
path: packages/cli/dist/cli-* path: packages/cli/dist/cli-*
if-no-files-found: error if-no-files-found: error
build-node-cli: build-node-app-archive:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -210,6 +234,7 @@ jobs:
host: macos-26 host: macos-26
- target: windows-arm64 - target: windows-arm64
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64 - target: windows-x64
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
@@ -221,14 +246,19 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
with: with:
install-flags: --os=* --cpu=* install-flags: ${{ matrix.settings.bun_install_flags }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "26.4.0" node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build - name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -543,6 +573,7 @@ jobs:
- version - version
- build-cli - build-cli
- sign-cli-macos - sign-cli-macos
- build-node-app-archive
- build-node-cli - build-node-cli
- sign-cli-windows - sign-cli-windows
- build-electron - build-electron
+13
View File
@@ -50,6 +50,14 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity - name: Configure git identity
run: | run: |
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
@@ -76,6 +84,11 @@ jobs:
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify compiled service lifecycle - name: Verify compiled service lifecycle
if: always() if: always()
timeout-minutes: 10 timeout-minutes: 10
File diff suppressed because it is too large Load Diff
+1 -17
View File
@@ -1,19 +1,3 @@
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json"
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
} }
+2 -2
View File
@@ -1,5 +1,5 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`. - The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. - Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views. - Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work. - Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior. - Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session. - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop. - Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
+1719 -2097
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=", "x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=", "aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=", "aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo=" "x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
} }
} }
+18 -18
View File
@@ -15,42 +15,44 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev", "dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint", "lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3", "typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts", "typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts", "typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky", "prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser", "sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1" "test": "echo 'do not run tests from root' && exit 1"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/*", "packages/*",
"packages/console/*", "packages/console/*",
"packages/stats/*", "packages/stats/*"
"packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.107", "@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-beta.107", "@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-beta.107", "@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-beta.107", "@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.4", "@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.4", "@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.4", "@opentui/solid": "0.5.7",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0", "@standard-schema/spec": "1.1.0",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -69,9 +71,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2", "drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-beta.107", "effect": "4.0.0-rc.111",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
@@ -127,7 +129,6 @@
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3", "heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:" "typescript": "catalog:"
}, },
@@ -165,7 +166,6 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -173,7 +173,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
} }
} }
+6 -11
View File
@@ -71,7 +71,7 @@ export const route = Route.make({
}) })
``` ```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s. Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id: Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
```ts ```ts
const openai = OpenAI.configure({ apiKey, baseURL, store: false }) const openai = OpenAI.configure({ apiKey, baseURL })
const model = openai.responses("gpt-4o-mini") const model = openai.responses("gpt-4o-mini")
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" }) const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
@@ -108,22 +108,17 @@ Keep provider facades small and explicit:
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash). - Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`. - Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior. `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints ### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults. Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts ```ts
import { model } from "@opencode-ai/ai/providers/openai/responses" import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model({ const selected = model("gpt-5", {
id: "gpt-5", apiKey,
settings: {},
credential: { type: "key", value: apiKey },
defaults: {},
}) })
``` ```
@@ -218,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
- Input failed the `parameters` Schema. - Input failed the `parameters` Schema.
- The handler returned a `ToolFailure`. - The handler returned a `ToolFailure`.
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched: Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`. - Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it. - Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
+11 -56
View File
@@ -305,26 +305,18 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
``` ```
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider. Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
### Package-like entrypoints ### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults. Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
```ts ```ts
import { model } from "@opencode-ai/ai/providers/openai/responses" import { model } from "@opencode-ai/ai/providers/openai/responses"
const apiKey = process.env.OPENAI_API_KEY const selected = model("gpt-5", {
if (!apiKey) throw new Error("OPENAI_API_KEY is required") apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
},
}) })
``` ```
@@ -348,57 +340,30 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
```ts ```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini" import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model({ model("gemini-3.5-flash", { project: "my-project", location: "global" })
id: "gemini-3.5-flash",
settings: { project: "my-project", location: "global" },
defaults: {},
})
``` ```
```ts ```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat" import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model({ model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { project: "my-project", location: "global" },
defaults: {},
})
``` ```
```ts ```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses" import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model({ model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
id: "xai/grok-4.20-reasoning",
settings: { project: "my-project", location: "global" },
defaults: {},
})
``` ```
```ts ```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages" import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model({ model("claude-sonnet-4-6", { project: "my-project", location: "global" })
id: "claude-sonnet-4-6",
settings: { project: "my-project", location: "global" },
defaults: {},
})
``` ```
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`. Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
## How OpenCode uses this package Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
## Provider options & HTTP overlays ## Provider options & HTTP overlays
@@ -411,16 +376,6 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis. Route/provider defaults are overridden by request-level values for each axis.
Provider-specific facades accept their own options directly because the provider is already known:
```ts
const model = OpenAI.configure({
apiKey,
store: false,
reasoningEffort: "high",
}).responses("gpt-5")
```
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral: The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts ```ts
+7 -6
View File
@@ -17,12 +17,13 @@ import { OpenAI } from "@opencode-ai/ai/providers"
const apiKey = Config.redacted("OPENAI_API_KEY") const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol // 1. Pick a model. The provider helper records provider identity, protocol
// choice, deployment options, authentication, and defaults. Catalog capabilities // choice, capabilities, deployment options, authentication, and defaults.
// remain application-owned and are not part of LanguageModel.
const model = OpenAI.configure({ const model = OpenAI.configure({
apiKey, apiKey,
generation: { maxTokens: 160 }, generation: { maxTokens: 160 },
store: false, providerOptions: {
store: false,
},
}).model("gpt-4o-mini") }).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request // 2. Build a provider-neutral request. This is useful when reusing one request
@@ -73,8 +74,8 @@ const streamText = LLM.stream(request).pipe(
Stream.runDrain, Stream.runDrain,
) )
// 5. Tools are typed with Effect Schema. Model calls remain explicit: // 5. Tools are typed with Effect Schema. Provider turns remain explicit:
// advertise definitions on the request, stream one call, dispatch local calls, // advertise definitions on the request, stream one turn, dispatch local calls,
// then persist/build follow-up history in the enclosing product flow. // then persist/build follow-up history in the enclosing product flow.
const tools = { const tools = {
get_weather: Tool.make({ get_weather: Tool.make({
@@ -101,7 +102,7 @@ const streamWithTools = Effect.gen(function* () {
console.log("tool result", event.name, dispatched.result) console.log("tool result", event.name, dispatched.result)
// A durable agent would persist these messages before starting another // A durable agent would persist these messages before starting another
// model call. This tutorial keeps the boundary visible instead. // raw model turn. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, { const followUp = LLMRequest.update(request, {
messages: [ messages: [
...request.messages, ...request.messages,
+1 -1
View File
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse, response: HttpClientResponse.HttpClientResponse,
) { ) {
if (response.status >= 200 && response.status < 300) return undefined if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}` return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
}) })
-3
View File
@@ -37,9 +37,6 @@ export type {
LanguageModelOptions as ProviderLanguageModelOptions, LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider.js" } from "./provider.js"
export type { export type {
Credential as ProviderPackageCredential,
Defaults as ProviderPackageDefaults,
Definition as ProviderPackageDefinition, Definition as ProviderPackageDefinition,
ModelInput as ProviderPackageModelInput,
Settings as ProviderPackageSettings, Settings as ProviderPackageSettings,
} from "./provider-package.js" } from "./provider-package.js"
+55 -28
View File
@@ -31,6 +31,20 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages" const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages" export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const SSE_EVENTS = new Set([
"message",
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingInput = export type ThinkingInput =
| { | {
@@ -40,7 +54,7 @@ export type ThinkingInput =
| { | {
readonly type: "disabled" readonly type: "disabled"
} }
| ({ readonly type: "enabled" } & ( | ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| { readonly budgetTokens: number; readonly budget_tokens?: number } | { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number } | { readonly budgetTokens?: number; readonly budget_tokens: number }
)) ))
@@ -199,6 +213,7 @@ const AnthropicThinking = Schema.Union([
Schema.Struct({ Schema.Struct({
type: Schema.tag("enabled"), type: Schema.tag("enabled"),
budget_tokens: Schema.Number, budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("adaptive"), type: Schema.tag("adaptive"),
@@ -233,7 +248,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest( const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
input_tokens: Schema.optional(Schema.Number), input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number), output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number), cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number), cache_read_input_tokens: optionalNull(Schema.Number),
@@ -361,16 +376,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "tool" as const, name }), tool: (name) => ({ type: "tool" as const, name }),
}) })
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
type: "tool_use", type: "tool_use",
id: part.id, id: scrubToolCallID(part.id),
name: part.name, name: part.name,
input: part.input, input: part.input,
}) })
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
type: "server_tool_use", type: "server_tool_use",
id: part.id, id: scrubToolCallID(part.id),
name: part.name, name: part.name,
input: part.input, input: part.input,
}) })
@@ -392,7 +409,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for // Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events. // histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
}) })
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -574,7 +591,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
content.push({ content.push({
type: "tool_result", type: "tool_result",
tool_use_id: part.id, tool_use_id: scrubToolCallID(part.id),
content: yield* lowerToolResultContent(part), content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined, is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache), cache_control: cacheControl(breakpoints, part.cache),
@@ -599,15 +616,12 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) { const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "adaptive") { const display =
const display = input.display === "summarized" || input.display === "omitted"
input.display === "summarized" ? (input.display as "summarized" | "omitted")
? ("summarized" as const) : undefined
: input.display === "omitted" if (input.type === "adaptive")
? ("omitted" as const)
: undefined
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
}
if (input.type === "disabled") return { type: "disabled" as const } if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined if (input.type !== "enabled") return undefined
const budget = const budget =
@@ -618,13 +632,12 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined : undefined
if (budget === undefined) if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens") return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget } return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
}) })
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system → // Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers // messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first. // over-mark we keep their tool hints and shed the message-tail ones first.
@@ -663,7 +676,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools, tools,
tool_choice: toolChoice, tool_choice: toolChoice,
stream: true as const, stream: true as const,
max_tokens: generation?.maxTokens ?? outputLimit, max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
top_k: generation?.topK, top_k: generation?.topK,
@@ -692,7 +705,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`. // expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const nonCached = usage.input_tokens const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite) const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
@@ -780,7 +793,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const block = event.content_block const block = event.content_block
if (!block) return [state, NO_EVENTS] if (!block) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [ return [
@@ -788,7 +802,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
...state, ...state,
lifecycle, lifecycle,
tools: ToolStream.start(state.tools, event.index, { tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index), id: block.id,
name: block.name ?? "", name: block.name ?? "",
input: input:
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0) block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
@@ -800,7 +814,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
[ [
...events, ...events,
LLMEvent.toolInputStart({ LLMEvent.toolInputStart({
id: block.id ?? String(event.index), id: block.id,
name: block.name ?? "", name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined, providerExecuted: block.type === "server_tool_use" ? true : undefined,
}), }),
@@ -990,15 +1004,28 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
} }
const onError = (event: AnthropicEvent) => const onError = (event: AnthropicEvent) =>
new AIError({ Effect.fail(
module: ADAPTER, new AIError({
method: "stream", module: ADAPTER,
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }), method: "stream",
}) reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
const step = (state: ParserState, event: AnthropicEvent) => { const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event)) if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event) if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event) if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
@@ -1039,7 +1066,7 @@ export const route = Route.make({
protocol, protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none, auth: Auth.none,
framing: Framing.sse, framing,
headers: () => ({ "anthropic-version": "2023-06-01" }), headers: () => ({ "anthropic-version": "2023-06-01" }),
}) })
+122 -68
View File
@@ -17,7 +17,7 @@ import {
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
} from "../schema/index.js" } from "../schema/index.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js" import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js" import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -37,6 +37,17 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID) return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
} }
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
const omitsFunctionCallIds = (modelID: string) => {
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
return match !== null && Number(match[1]) < 3
}
export interface OptionsInput { export interface OptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly cachedContent?: string readonly cachedContent?: string
@@ -71,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
// ============================================================================= // =============================================================================
// Gemini is known to send explicit `null` for optional streaming fields
// (usage counts, flags, whole subtrees), so every response-side optional uses
// `optionalNull` instead of bare `Schema.optional`. The same part/content
// schemas lower the outbound request body; encoding drops `undefined` keys,
// so the shared schemas stay safe there.
const GeminiTextPart = Schema.Struct({ const GeminiTextPart = Schema.Struct({
text: Schema.String, text: Schema.String,
thought: Schema.optional(Schema.Boolean), thought: optionalNull(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String), thoughtSignature: optionalNull(Schema.String),
}) })
const GeminiInlineDataPart = Schema.Struct({ const GeminiInlineDataPart = Schema.Struct({
@@ -87,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({ const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({ functionCall: Schema.Struct({
id: Schema.optional(Schema.String), id: optionalNull(Schema.String),
name: Schema.String, name: Schema.String,
args: Schema.optional(Schema.Unknown), args: Schema.optional(Schema.Unknown),
}), }),
thoughtSignature: Schema.optional(Schema.String), thoughtSignature: optionalNull(Schema.String),
}) })
const GeminiFunctionResponsePart = Schema.Struct({ const GeminiFunctionResponsePart = Schema.Struct({
@@ -111,8 +127,8 @@ const GeminiContentPart = Schema.Union([
]) ])
const GeminiContent = Schema.Struct({ const GeminiContent = Schema.Struct({
role: Schema.Literals(["user", "model"]), role: optionalNull(Schema.Literals(["user", "model"])),
parts: Schema.Array(GeminiContentPart), parts: optionalNull(Schema.Array(GeminiContentPart)),
}) })
type GeminiContent = Schema.Schema.Type<typeof GeminiContent> type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
@@ -175,44 +191,45 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody> export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
const GeminiUsage = Schema.Struct({ const GeminiUsage = Schema.Struct({
cachedContentTokenCount: Schema.optional(Schema.Number), cachedContentTokenCount: optionalNull(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number), thoughtsTokenCount: optionalNull(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number), promptTokenCount: optionalNull(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number), candidatesTokenCount: optionalNull(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number), totalTokenCount: optionalNull(Schema.Number),
}) })
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage> type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({ const GeminiCandidate = Schema.Struct({
content: Schema.optional(GeminiContent), content: optionalNull(GeminiContent),
finishReason: Schema.optional(Schema.String), finishReason: optionalNull(Schema.String),
}) })
const GeminiPromptFeedback = Schema.StructWithRest( const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
blockReason: Schema.optional(Schema.String), blockReason: optionalNull(Schema.String),
blockReasonMessage: Schema.optional(Schema.String), blockReasonMessage: optionalNull(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown), safetyRatings: optionalNull(Schema.Unknown),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
) )
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback> type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({ const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate), candidates: optionalNull(Schema.Array(GeminiCandidate)),
promptFeedback: Schema.optional(GeminiPromptFeedback), promptFeedback: optionalNull(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage), usageMetadata: optionalNull(GeminiUsage),
}) })
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent> type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState { interface ParserState {
readonly finishReason?: string readonly finishReason?: string
readonly hasToolCalls: boolean readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage readonly usage?: Usage
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string readonly reasoningSignature?: string
readonly textSignature?: string
readonly seenCallIds?: ReadonlySet<string>
} }
// ============================================================================= // =============================================================================
@@ -270,27 +287,31 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
: undefined : undefined
} }
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => { const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
const google = providerMetadata?.google functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
? google.functionCallId
: undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata), thoughtSignature: thoughtSignature(part.providerMetadata),
}) })
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = [] const contents: GeminiContent[] = []
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
if (!pendingMedia) return
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
pendingMedia = undefined
}
for (const message of request.messages) { for (const message of request.messages) {
if (message.role !== "tool") flushMedia()
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message) const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1) const previous = contents.at(-1)
if (previous?.role === "user") // Gemini rejects a continuation whose function-response turn carries extra
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] } // parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] }) else contents.push({ role: "user", parts: [{ text: part.text }] })
continue continue
} }
@@ -314,7 +335,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") { if (part.type === "text") {
parts.push({ text: part.text }) parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue continue
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
@@ -322,7 +343,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
const lowered = lowerToolCall(part) const lowered = lowerToolCall(part, omitCallIds)
const signature = lowered.thoughtSignature const signature = lowered.thoughtSignature
parts.push({ parts.push({
...lowered, ...lowered,
@@ -347,7 +368,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") { if (part.result.type !== "content") {
parts.push({ parts.push({
functionResponse: { functionResponse: {
id: functionCallId(part.providerMetadata), ...(omitCallIds ? {} : { id: part.id }),
name: part.name, name: part.name,
response: { response: {
name: part.name, name: part.name,
@@ -365,21 +386,28 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const value = ProviderShared.normalizeToolFile(item) const value = ProviderShared.normalizeToolFile(item)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } }) media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
} }
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({ parts.push({
functionResponse: { functionResponse: {
id: functionCallId(part.providerMetadata), ...(omitCallIds ? {} : { id: part.id }),
name: part.name, name: part.name,
response: { response: {
name: part.name, name: part.name,
content: text.join("\n"), content: text.join("\n"),
}, },
parts: media.length > 0 ? media : undefined, parts: legacyToolMedia || media.length === 0 ? undefined : media,
}, },
}) })
} }
contents.push({ role: "user", parts }) // Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
else contents.push({ role: "user", parts })
} }
flushMedia()
return contents return contents
}) })
@@ -465,21 +493,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// to produce the inclusive `outputTokens` the rest of the contract expects. // to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => { const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined if (!usage) return undefined
const cached = usage.cachedContentTokenCount // Explicit provider nulls decode as `null`; normalize to `undefined` so the
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) // token arithmetic below treats them like absent counts.
const promptTokens = usage.promptTokenCount ?? undefined
const cached = usage.cachedContentTokenCount ?? undefined
const thoughts = usage.thoughtsTokenCount ?? undefined
const visible = usage.candidatesTokenCount ?? undefined
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the // `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total // inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an // when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown. // inclusive number from a partial breakdown.
const outputTokens = const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
return new Usage({ return new Usage({
inputTokens: usage.promptTokenCount, inputTokens: promptTokens,
outputTokens, outputTokens,
nonCachedInputTokens: nonCached, nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached, cacheReadInputTokens: cached,
reasoningTokens: usage.thoughtsTokenCount, reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage }, providerMetadata: { google: usage },
}) })
} }
@@ -514,19 +546,24 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
} }
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => { const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined // `?? undefined` normalizes an explicit `null` blockReason back to absent so
// the "nothing to finish" check below keeps its meaning.
const promptBlockReason =
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
const finishReason = state.finishReason ?? promptBlockReason const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return [] if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature let lifecycle = state.lifecycle
? Lifecycle.reasoningEnd( if (state.reasoningSignature !== undefined)
state.lifecycle, lifecycle = Lifecycle.reasoningEnd(
events, lifecycle,
"reasoning-0", events,
googleMetadata({ thoughtSignature: state.reasoningSignature }), "reasoning-0",
) googleMetadata({ thoughtSignature: state.reasoningSignature }),
: state.lifecycle )
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
Lifecycle.finish(lifecycle, events, { Lifecycle.finish(lifecycle, events, {
reason: { reason: {
normalized: normalized:
@@ -556,12 +593,17 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const part of candidate.content.parts) { for (const part of candidate.content.parts ?? []) {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
reasoningSignature = part.thoughtSignature // Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
else if (signature !== undefined && "text" in part) textSignature = signature
if ("text" in part && part.text.length > 0) { if ("text" in part && part.text.length > 0) {
if (part.thought) { if (part.thought) {
lifecycle = Lifecycle.reasoningDelta( lifecycle = Lifecycle.reasoningDelta(
@@ -569,7 +611,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events, events,
"reasoning-0", "reasoning-0",
part.text, part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
) )
continue continue
} }
@@ -579,17 +621,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0", "reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
) )
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue continue
} }
if ("functionCall" in part) { if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}` // Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
const metadata = { // generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }), // A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }), // A `null` supplier id normalizes to absent so the generated-id fallback applies.
} const supplied = part.functionCall.id ?? undefined
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
lifecycle = Lifecycle.reasoningEnd( lifecycle = Lifecycle.reasoningEnd(
lifecycle, lifecycle,
events, events,
@@ -602,7 +654,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
id, id,
name: part.functionCall.name, name: part.functionCall.name,
input, input,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined, providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
}), }),
) )
hasToolCalls = true hasToolCalls = true
@@ -614,8 +667,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState, ...nextState,
hasToolCalls, hasToolCalls,
lifecycle, lifecycle,
nextToolCallId,
reasoningSignature, reasoningSignature,
textSignature,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason, finishReason: candidate.finishReason ?? nextState.finishReason,
}, },
events, events,
@@ -637,7 +691,7 @@ export const protocol = Protocol.make({
}, },
stream: { stream: {
event: Protocol.jsonEvent(GeminiEvent), event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step, step,
onHalt: finish, onHalt: finish,
}, },
+1
View File
@@ -8,3 +8,4 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js" export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js" export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js" export * as OpenResponsesChannel from "./open-responses-channel.js"
export * as XAIResponses from "./xai-responses.js"
@@ -10,6 +10,7 @@ import {
} from "../route/transport/index.js" } from "../route/transport/index.js"
import * as ProviderShared from "./shared.js" import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js" import { OpenResponses } from "./open-responses.js"
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown), Schema.Record(Schema.String, Schema.Unknown),
@@ -22,12 +23,9 @@ export interface Options {
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly rotateAfterMs?: number readonly rotateAfterMs?: number
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
} }
export interface Prepared { export interface Prepared {
@@ -114,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created responseID = created
return { type: "frame", frame } return { type: "frame", frame }
} }
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID) if (!responseID)
return yield* ProviderShared.eventError( return yield* ProviderShared.eventError(
options.id, options.id,
@@ -147,18 +147,25 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () { Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input) const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length") const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket const channel =
? yield* Effect.gen(function* () { input.webSocket && (options.enabled?.(parts.url) ?? true)
const create = yield* message(parts.jsonBody) ? yield* Effect.gen(function* () {
const base = driver(options, create.message) const create = yield* message(parts.jsonBody)
return { const base = driver(options, create.message)
url: yield* WebSocketTransport.toWebSocketUrl(parts.url), return {
headers, url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
rotateAfterMs: options.rotateAfterMs, headers,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base, rotateAfterMs: options.rotateAfterMs,
} driver: OpenResponsesContinuation.driver({
}) id: options.id,
: undefined name: options.name,
request: create.request,
message: create.message,
base,
}),
}
})
: undefined
return { return {
http: { http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }), request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js" import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js" import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "openai-responses.websocket.v1" const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1 const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event) const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
} }
} }
export const OpenAIResponsesChannel = { driver } as const export const OpenResponsesContinuation = { driver } as const
+179 -89
View File
@@ -5,6 +5,7 @@ import { Protocol } from "../route/protocol.js"
import { import {
AIError, AIError,
LLMEvent, LLMEvent,
ProviderInternalReason,
Usage, Usage,
type FinishReason, type FinishReason,
type JsonSchema, type JsonSchema,
@@ -42,8 +43,12 @@ const OpenResponsesInputImage = Schema.Struct({
const OpenResponsesInputFile = Schema.Struct({ const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"), type: Schema.tag("input_file"),
filename: Schema.String, filename: Schema.String,
file_data: Schema.String, file_data: Schema.optional(Schema.String),
mime_type: Schema.optional(Schema.String), file_url: Schema.optional(Schema.String),
})
const OpenResponsesInputVideo = Schema.Struct({
type: Schema.tag("input_video"),
video_url: Schema.String,
}) })
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile]) const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
export type MediaInput = Schema.Schema.Type<typeof MediaInput> export type MediaInput = Schema.Schema.Type<typeof MediaInput>
@@ -54,9 +59,14 @@ const OpenResponsesOutputText = Schema.Struct({
text: Schema.String, text: Schema.String,
}) })
export const MessagePhase = Schema.Literals(["commentary", "final_answer"]) export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase> type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
const OpenResponsesReasoningSummaryText = Schema.Struct({ const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"), type: Schema.tag("summary_text"),
text: Schema.String, text: Schema.String,
@@ -81,6 +91,7 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
OpenResponsesInputText, OpenResponsesInputText,
OpenResponsesInputImage, OpenResponsesInputImage,
OpenResponsesInputFile, OpenResponsesInputFile,
OpenResponsesInputVideo,
]) ])
const OpenResponsesFunctionCallOutput = Schema.Union([ const OpenResponsesFunctionCallOutput = Schema.Union([
@@ -93,6 +104,8 @@ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }), Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText), content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase), phase: Schema.optionalKey(MessagePhase),
@@ -101,6 +114,7 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference, OpenResponsesItemReference,
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call"), type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
name: Schema.String, name: Schema.String,
arguments: Schema.String, arguments: Schema.String,
@@ -115,6 +129,8 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem = type LoweredInputItem =
| OpenResponsesInputItem | OpenResponsesInputItem
| { | {
readonly type: "message"
readonly id?: string
readonly role: "assistant" readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }> readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null readonly phase?: MessagePhase | null
@@ -128,8 +144,6 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }> summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null encrypted_content?: string | null
} }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({ export const Tool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
name: Schema.String, name: Schema.String,
@@ -159,6 +173,14 @@ export const coreFields = {
tools: optionalArray(Tool), tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice), tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema), truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema), service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String), prompt_cache_key: Schema.optional(Schema.String),
@@ -179,6 +201,8 @@ export const coreFields = {
parallel_tool_calls: Schema.optional(Schema.Boolean), parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
} }
const OpenResponsesBody = Schema.Struct({ const OpenResponsesBody = Schema.Struct({
@@ -224,6 +248,7 @@ const OpenResponsesErrorPayload = Schema.Struct({
message: optionalNull(Schema.String), message: optionalNull(Schema.String),
param: optionalNull(Schema.String), param: optionalNull(Schema.String),
}) })
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]) const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest( export const WebSocketErrorEvent = Schema.StructWithRest(
@@ -296,7 +321,6 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest readonly request: LLMRequest
}) => MediaInput | undefined }) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
} }
const BASE: Extension = { id: ADAPTER, name: NAME } const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -309,7 +333,6 @@ export interface ParserState {
readonly hasFunctionCall: boolean readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string> readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>> readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>> readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined readonly store: boolean | undefined
@@ -339,7 +362,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
type: "function" as const, type: "function" as const,
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.responses(inputSchema), parameters: inputSchema,
// The common tool definition does not currently express Responses strict-schema policy. // The common tool definition does not currently express Responses strict-schema policy.
strict: false, strict: false,
} }
@@ -353,52 +376,63 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }), tool: (toolName) => ({ type: "function" as const, name: toolName }),
}) })
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
type: "function_call", const metadata = providerMetadata?.[providerMetadataKey]
call_id: part.id, return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
name: part.name, ? metadata.itemId
arguments: ProviderShared.encodeJson(part.input), : undefined
}) }
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => { const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0) const id = itemID(part.providerMetadata, providerMetadataKey)
return undefined if (!ProviderShared.isRecord(metadata) || !id) return undefined
const encryptedContent = const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent ? metadata.reasoningEncryptedContent
: undefined : undefined
return { return {
type: "reasoning", type: "reasoning",
id: metadata.itemId, id,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent, encrypted_content: encryptedContent,
} }
} }
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart, part: MediaPart,
request: LLMRequest, request: LLMRequest,
extension: Extension, extension: Extension,
target: "message" | "tool-result",
) { ) {
const media = ProviderShared.normalizeMedia(part) const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request }) const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
: undefined
if (!media.mime.startsWith("image/")) { if (!media.mime.startsWith("image/")) {
if (target === "tool-result" && media.mime.startsWith("video/"))
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
return { return {
type: "input_file" as const, type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"), filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
file_data: media.dataUrl, ...(url ? { file_url: url } : { file_data: media.dataUrl }),
} }
} }
return { type: "input_image" as const, image_url: media.dataUrl } return { type: "input_image" as const, image_url: url ?? media.dataUrl }
}) })
const lowerUserContent = Effect.fnUntraced(function* ( const lowerUserContent = Effect.fnUntraced(function* (
@@ -407,10 +441,17 @@ const lowerUserContent = Effect.fnUntraced(function* (
extension: Extension, extension: Extension,
) { ) {
if (part.type === "text") return { type: "input_text" as const, text: part.text } if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMedia(part, request, extension) if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"]) return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
}) })
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
// Tool results may carry structured text, images, and files. Keep media as provider-native // Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string. // content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* ( const lowerToolResultContentItem = Effect.fnUntraced(function* (
@@ -423,6 +464,20 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, { type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request, request,
extension, extension,
"tool-result",
)
})
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
) )
}) })
@@ -465,24 +520,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") { if (message.role === "assistant") {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {} const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>() const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>() const hostedToolReferences = new Set<string>()
const flushText = () => { const flushText = () => {
if (content.length === 0) return if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<
(groups, part) => { Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
const metadata = part.providerMetadata?.[providerMetadataKey] >((groups, part) => {
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined const metadata = part.providerMetadata?.[providerMetadataKey]
const group = groups.at(-1) const id = itemID(part.providerMetadata, providerMetadataKey)
if (group && group.phase === phase) group.parts.push(part) const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
else groups.push({ phase, parts: [part] }) const group = groups.at(-1)
return groups if (group && group.id === id && group.phase === phase) group.parts.push(part)
}, else groups.push({ id, phase, parts: [part] })
[], return groups
) }, [])
input.push( input.push(
...groups.map((group) => ({ ...groups.map((group) => ({
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const, role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })), content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }), ...(group.phase === undefined ? {} : { phase: group.phase }),
@@ -511,34 +568,35 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content existing.encrypted_content = reasoning.encrypted_content
continue continue
} }
const replay = { reasoningItems[reasoning.id] = reasoning
type: reasoning.type, input.push(reasoning)
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
flushText() flushText()
if (part.providerExecuted === true) continue if (part.providerExecuted === true) continue
input.push(lowerToolCall(part)) input.push(lowerToolCall(part, providerMetadataKey))
continue continue
} }
if (part.type === "tool-result" && part.providerExecuted === true) { if (part.type === "tool-result" && part.providerExecuted === true) {
flushText() flushText()
const itemID = hostedToolItemID(part, providerMetadataKey) const id = itemID(part.providerMetadata, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
input.push({ type: "item_reference", id: itemID }) if (store === false) {
if (store === false && part.result.type === "content") { // The server is not storing this exchange, so the tool outcome has to
const content: ReadonlyArray<Content> = part.result.value // travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({ input.push({
role: "user", role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)), content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
}) })
} }
if (itemID) hostedToolReferences.add(itemID) if (id) hostedToolReferences.add(id)
continue continue
} }
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -575,10 +633,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => { const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request) const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return { return {
...(options.instructions ? { instructions: options.instructions } : {}), ...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.include ? { include: options.include } : {}), ...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary ...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } } ? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -627,6 +692,8 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
max_output_tokens: generation?.maxTokens, max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request), ...lowerOptions(request),
} }
}) })
@@ -695,7 +762,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events] return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
} }
@@ -742,18 +809,17 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed. // best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item const item = event.item
if (item?.type === "message" && item.id) if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
return [ return [
{ {
...state, ...state,
messageItems: new Set([...state.messageItems, item.id]), messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => { messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
}, },
NO_EVENTS, NO_EVENTS,
] ]
}
if (item && isReasoningItem(item)) { if (item && isReasoningItem(item)) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
return [ return [
@@ -911,7 +977,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) { if (item.type === "message" && item.id) {
const itemPhase = state.messagePhase(item.phase) const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems) const messageItems = new Set(state.messageItems)
@@ -924,7 +990,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle, state.lifecycle,
events, events,
item.id, item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }), providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
), ),
messageItems, messageItems,
messagePhases, messagePhases,
@@ -937,7 +1003,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id] const tools = state.tools[item.id]
? state.tools ? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) : ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
const result = const result =
item.arguments === undefined item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id) ? yield* ToolStream.finish(state.id, tools, item.id)
@@ -988,11 +1058,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult return [state, NO_EVENTS] satisfies StepResult
}) })
const onResponseFinish = (state: ParserState, event: Event): StepResult => { const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const events: LLMEvent[] = [] // Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, { const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: { reason: {
normalized: mapFinishReason(event, state.hasFunctionCall), normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason, raw: event.response?.incomplete_details?.reason,
}, },
usage: mapUsage(event.response?.usage, state.providerMetadataKey), usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -1004,35 +1082,48 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
}) })
: undefined, : undefined,
}) })
return [{ ...state, lifecycle }, events] return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
} })
// Build a single human-readable message from whatever the provider supplied. // Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see // When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just // the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used // the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. // to be indistinguishable from generic stream drops. Returns undefined when
const providerErrorMessage = (event: Event, fallback: string): string => { // the payload carries no usable summary.
const nested = event.error ?? event.response?.error ?? undefined const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}` if (message && code) return `${code}: ${message}`
return message || code || fallback return message || code
} }
export const providerFailure = (id: string, event: Event, fallback: string) => { export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined const nested = event.error ?? event.response?.error ?? undefined
const message = providerErrorMessage(event, fallback) const code = event.code || nested?.code || undefined
// Keep the full raw payload on the error even when the message is a summary.
const body = JSON.stringify(nested ?? event) ?? ""
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status = const status =
typeof event.status === "number" typeof event.status === "number"
? event.status ? event.status
: typeof event.status_code === "number" : typeof event.status_code === "number"
? event.status_code ? event.status_code
: undefined : undefined
const reason =
event.type === "error" &&
event.error === undefined &&
event.response === undefined &&
summary === undefined &&
status === undefined
? new ProviderInternalReason({ message })
: classifyProviderFailure({ message, code, status, rawBody: body })
return new AIError({ return new AIError({
module: id, module: id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code, status }), body,
reason,
}) })
} }
@@ -1047,14 +1138,20 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id), : onOutputTextDone(state, event, event.item_id),
) )
} }
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") { if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id)) return Effect.succeed(onReasoningDelta(state, event, event.item_id))
} }
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added") if (event.type === "response.reasoning_summary_part.added")
return event.item_id return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event)) ? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -1074,8 +1171,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`) return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event) return onOutputItemDone(state, event)
} }
if (event.type === "response.completed" || event.type === "response.incomplete") if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`) if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") if (event.type === "error")
return decodeKnownErrorEvent(event).pipe( return decodeKnownErrorEvent(event).pipe(
@@ -1100,17 +1196,11 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(), tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(), lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(), messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {}, messagePhases: {},
reasoningItems: {}, reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store, store: OpenResponsesOptions.resolve(request).store,
}) })
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
if (value === "commentary" || value === "final_answer") return value
return extension.messagePhase?.(value)
}
export const protocol = Protocol.make({ export const protocol = Protocol.make({
id: ADAPTER, id: ADAPTER,
body: { body: {
+57 -12
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js" import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat" const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]) const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions" export const PATH = "/chat/completions"
@@ -156,6 +156,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number), prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number), completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number), total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
cached_tokens: optionalNull(Schema.Number),
prompt_cache_hit_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull( prompt_tokens_details: optionalNull(
Schema.StructWithRest( Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
@@ -194,6 +197,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest( const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
content: optionalNull(Schema.String), content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String), reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String), reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String), reasoning_text: optionalNull(Schema.String),
@@ -203,11 +207,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
) )
const OpenAIChatChoice = Schema.Struct({ const OpenAIChatChoice = Schema.StructWithRest(
delta: optionalNull(OpenAIChatDelta), Schema.Struct({
finish_reason: optionalNull(Schema.String), delta: optionalNull(OpenAIChatDelta),
native_finish_reason: optionalNull(Schema.String), finish_reason: optionalNull(Schema.String),
}) native_finish_reason: optionalNull(Schema.String),
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
usage: optionalNull(OpenAIChatUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({ const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])), code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
@@ -260,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
function: { function: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema), parameters: inputSchema,
}, },
cache_control: options.cacheControl?.(tool.cache), cache_control: options.cacheControl?.(tool.cache),
}) })
@@ -508,11 +517,23 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
return messages return messages
}) })
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
// whenever the conversation history contains tool calls/results. Send an
// explicit empty array when we have history but no active tools.
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
for (const message of messages) {
if (message.role === "tool") return true
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
}
return false
}
const lowerOptions = (request: LLMRequest) => { const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request) const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return { return {
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
} }
} }
@@ -531,12 +552,15 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens" const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
return { return {
model: request.model.id, model: request.model.id,
messages: yield* lowerMessages(request, options), messages: yield* lowerMessages(request, options),
tools: tools:
request.tools.length === 0 request.tools.length === 0
? undefined ? hasHistory
? []
: undefined
: request.tools.map((tool) => : request.tools.map((tool) =>
lowerTool( lowerTool(
tool, tool,
@@ -580,11 +604,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// total) with a `reasoning_tokens` subset. We pass the inclusive totals // total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `AI.Usage` contract is // through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides. // satisfied on both sides.
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined const output = usage.completion_tokens ?? undefined
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined const cached =
(usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite)) const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
@@ -690,8 +721,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
}), }),
}) })
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
const choice = event.choices?.[0] const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason const rawFinishReason = choice?.finish_reason
const finishReason = const finishReason =
rawFinishReason !== undefined && rawFinishReason !== null rawFinishReason !== undefined && rawFinishReason !== null
@@ -709,6 +743,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField) const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent = const hasLateContent =
Boolean(delta?.content) || Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined || reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) || (Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments)) toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
@@ -728,7 +763,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if ( else if (
reasoningDetailsObserved && reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") && !lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0) (Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
) )
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata) lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0") const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -743,6 +778,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
} }
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use // Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks. // batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) { for (const [position, tool] of toolDeltas.entries()) {
+1 -1
View File
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
const multipartImages = yield* Effect.forEach(sourceImages, (image) => { const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType }) if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER) if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.succeed(undefined) return Effect.undefined
}) })
const multipartMask = const multipartMask =
mask === undefined mask === undefined
+22 -93
View File
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js" import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js" import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js" import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js" import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js" import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js" import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses" const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses" const NAME = "OpenAI Responses"
@@ -40,18 +39,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }), Schema.Struct({ type: Schema.tag("image_generation") }),
]) ])
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
])
const OpenAIResponsesCoreFields = { const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields, ...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools), tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice), tool_choice: Schema.optional(OpenAIResponsesToolChoice),
} }
@@ -65,16 +54,6 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = { const extension = {
id: ADAPTER, id: ADAPTER,
name: NAME, name: NAME,
messagePhase: (value: unknown) => (value === null ? null : undefined),
lowerMedia: ({ part, media, request }) => {
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
return {
type: "input_file",
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
},
} satisfies OpenResponses.Extension } satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => { const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -126,46 +105,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
} satisfies OpenAIResponsesBody } satisfies OpenAIResponsesBody
}) })
type HostedToolData = OpenResponses.StreamItem & { const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
image_generation_call: { name: "image_generation", input: () => ({}) },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
type HostedToolType = keyof typeof HOSTED_TOOLS
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
const isError = item.error !== undefined && item.error !== null const isError = item.error !== undefined && item.error !== null
if (item.type === "image_generation_call" && item.result) { if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe( yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
@@ -186,32 +126,21 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item } return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
}) })
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* ( const HOSTED_TOOLS = {
state: OpenResponses.ParserState, web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
item: HostedToolItem, web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
) { file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
const tool = HOSTED_TOOLS[item.type] code_interpreter_call: {
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id }) name: "code_interpreter",
const events: LLMEvent[] = [] input: (item) => ({ code: item.code, container_id: item.container_id }),
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) },
events.push( computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
LLMEvent.toolCall({ image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
id: item.id, mcp_call: {
name: tool.name, name: "mcp",
input: tool.input(item), input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
providerExecuted: true, },
providerMetadata, } as const satisfies ResponsesHostedTools.Definitions
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => { const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta") if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
@@ -222,8 +151,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
return event.item_id return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event)) ? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`) : ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item)) if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return onHostedToolDone(state, event.item) return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event) return OpenResponses.step(state, event)
} }
@@ -245,12 +174,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>() export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({ export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
export const transport = channelTransport({
id: ADAPTER, id: ADAPTER,
name: NAME, name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS, rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER), headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
}) })
export const route = Route.make({ export const route = Route.make({
+24 -4
View File
@@ -24,6 +24,17 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema)) export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema)) export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
/** /**
* Streaming tool-call accumulator. Adapters that build a tool call across * Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here * multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -197,19 +208,28 @@ export const errorText = (error: unknown) => {
/** /**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, and drops empty / `[DONE]` keep-alive events so the protocol event * decoder, optionally filters named events, and drops empty / `[DONE]`
* schema sees one JSON string per element. The SSE channel emits a * keep-alive events so the protocol event schema sees one JSON string per
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't * `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output * implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`. * errors so the public error channel stays `AIError`.
*/ */
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> => export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
): Stream.Stream<string, AIError> =>
bytes.pipe( bytes.pipe(
Stream.decodeText(), Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()), Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty), Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))), Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"), Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data), Stream.map((event) => event.data),
) )
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
url: string, url: string,
module: string, module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => { ): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined) if (!url.startsWith("data:")) return Effect.undefined
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url) const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data")) if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe( return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
+8 -2
View File
@@ -21,9 +21,15 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
return { ...stepped, text: new Set([...stepped.text, id]) } return { ...stepped, text: new Set([...stepped.text, id]) }
} }
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
const started = textStart(state, events, id) const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text })) events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
return started return started
} }
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {}) export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
export type ServiceTier = (typeof ServiceTiers)[number] export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
export const ServiceTier = Schema.declare<ServiceTier>(
(value): value is ServiceTier => typeof value === "string",
{ title: "ServiceTier" },
)
export const Truncations = ["auto", "disabled"] as const export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number] export type Truncation = (typeof Truncations)[number]
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string", (value): value is ResponseIncludable => typeof value === "string",
{ title: "ResponseIncludable" }, { title: "ResponseIncludable" },
) )
export const ServiceTierSchema = Schema.Literals(ServiceTiers) export const ServiceTierSchema = ServiceTier
export const TruncationSchema = Schema.Literals(Truncations) export const TruncationSchema = Schema.Literals(Truncations)
export const AllowedTools = Schema.Struct({ export const AllowedTools = Schema.Struct({
@@ -47,9 +51,17 @@ export const AllowedTools = Schema.Struct({
}) })
export type AllowedTools = typeof AllowedTools.Type export type AllowedTools = typeof AllowedTools.Type
export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({ export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String), instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort), reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])), reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)), include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// in lockstep with `openai-node/src/resources/responses/responses.ts`. // in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
@@ -0,0 +1,70 @@
import { Effect } from "effect"
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
readonly results?: unknown
readonly code?: string
readonly container_id?: string
readonly outputs?: unknown
readonly server_label?: string
readonly output?: unknown
readonly result?: string
readonly output_format?: "png" | "jpeg" | "webp"
readonly error?: unknown
}
export interface Definition {
readonly name: string
readonly input: (item: Item) => unknown
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
}
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
item: Item,
tools: Definitions,
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
state,
item,
tools,
) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: tool.result
? yield* tool.result(item)
: item.error !== undefined && item.error !== null
? { type: "error", value: item.error }
: { type: "json", value: item },
providerExecuted: true,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
})
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
+1 -32
View File
@@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
import { isRecord } from "../../utils/record.js" import { isRecord } from "../../utils/record.js"
import { GeminiToolSchema } from "./gemini-tool-schema.js" import { GeminiToolSchema } from "./gemini-tool-schema.js"
const removeNullSchemas = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(removeNullSchemas)
if (!isRecord(value)) return value
const fields = Object.fromEntries(
Object.entries(value)
.filter(([key]) => key !== "anyOf")
.map(([key, field]) => [key, removeNullSchemas(field)]),
)
if (!Array.isArray(value.anyOf)) return fields
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
return { ...fields, anyOf: variants }
}
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => { const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
const projected = items.map(moonshotNode) const projected = items.map(moonshotNode)
if (projected.length === 0) return {} if (projected.length === 0) return {}
@@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
return isRecord(projected) ? projected : {} return isRecord(projected) ? projected : {}
} }
const openAI = (schema: JsonSchema): JsonSchema => { const openAI = (schema: JsonSchema): JsonSchema => schema
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
const flattened =
variants.length === 0
? { ...schema, type: "object" }
: {
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
type: "object",
properties: variants.reduce(
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
{},
),
additionalProperties: false,
}
const normalized = removeNullSchemas(flattened)
return isRecord(normalized) ? normalized : { type: "object" }
}
const responses = openAI const responses = openAI
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
@@ -0,0 +1,55 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const extension = {
id: ADAPTER,
name: NAME,
} satisfies OpenResponses.Extension
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
code_interpreter_call: {
name: "code_interpreter",
input: (item) => ({ code: item.code, container_id: item.container_id }),
},
image_generation_call: { name: "image_generation", input: () => ({}) },
mcp_call: {
name: "mcp",
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
}
export const protocol = Protocol.make({
id: ADAPTER,
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
})
export * as XAIResponses from "./xai-responses.js"
+9 -2
View File
@@ -74,11 +74,15 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure { export interface ProviderFailure {
readonly message: string readonly message: string
readonly status?: number | undefined readonly status?: number | undefined
readonly code?: string | undefined readonly code?: string | undefined
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
// that the summary message does not carry. Not shown to users.
readonly rawBody?: string | undefined
readonly retryAfterMs?: number | undefined readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined readonly http?: HttpContext | undefined
@@ -88,11 +92,13 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so // Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching. // session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] { export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? "" const body = input.http?.body ?? input.rawBody ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)] const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined) .filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase()) .map((code) => code.toLowerCase())
const text = body || input.message // Scan the raw payload too so signals missing from the summary message
// (e.g. overflow phrases nested in a JSON error body) still classify.
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http } const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500) const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
@@ -127,6 +133,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))) if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({ return new ProviderInternalReason({
...common, ...common,
+3 -49
View File
@@ -1,62 +1,16 @@
import { Auth } from "./route/auth.js"
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
import type { LanguageModel, ProviderOptions } from "./schema/index.js" import type { LanguageModel, ProviderOptions } from "./schema/index.js"
export interface Settings {} export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
export type Credential =
| {
readonly type: "key"
readonly value: string
readonly configuration?: Readonly<Record<string, unknown>>
}
| {
readonly type: "oauth"
readonly accessToken: string
}
export interface Defaults {
readonly headers?: Readonly<Record<string, string>> readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>> readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
} }
export interface ModelInput<ProviderSettings extends Settings = Settings> {
readonly id: string
readonly settings: ProviderSettings
readonly credential?: Credential
readonly defaults: Defaults
}
export const routeDefaults = (input: Defaults) => ({
headers: input.headers,
http: input.body === undefined ? undefined : { body: input.body },
limits: input.limits,
})
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
export const bearerAuthOption = (input: Credential): AuthOverride => ({
auth: Auth.bearer(bearerCredentialValue(input)),
})
export const apiKeyOrBearerAuthOption = (
input: Credential,
competingKeyHeader: string,
): RequiredApiKeyAuth | AuthOverride =>
input.type === "key"
? { apiKey: input.value }
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
export interface Definition< export interface Definition<
ProviderSettings extends Settings = Settings, ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions, Options extends ProviderOptions = ProviderOptions,
> { > {
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options> readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
} }
export * as ProviderPackage from "./provider-package.js" export * as ProviderPackage from "./provider-package.js"
@@ -1,6 +1,6 @@
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js" import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js" import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js" import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
@@ -79,27 +79,28 @@ export const configure = (input: Config = {}) => {
export const provider = configure() export const provider = configure()
const config = (input: ProviderPackage.ModelInput<Settings>): Config => { const config = (settings: Settings): Config => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined) if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey") throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined) if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey") throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return { return {
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
apiKey: input.credential baseURL: settings.baseURL,
? ProviderPackage.bearerCredentialValue(input.credential) credentials: settings.credentials,
: input.settings.auth === "sigv4" headers: settings.headers === undefined ? undefined : { ...settings.headers },
? undefined http: settings.body === undefined ? undefined : { body: { ...settings.body } },
: input.settings.apiKey, providerOptions: settings.providerOptions,
baseURL: input.settings.baseURL, region: settings.region,
credentials: input.settings.credentials,
providerOptions: input.settings.providerOptions,
region: input.settings.region,
} }
} }
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
configure(config(input)).chat(input.id) modelID,
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => settings,
configure(config(input)).responses(input.id) ) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel export const model = chatModel
+12 -15
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js" import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js" import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
@@ -50,21 +50,18 @@ export const configure = (input: Config = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined) if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey") throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined) if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
apiKey: input.credential baseURL: settings.baseURL,
? ProviderPackage.bearerCredentialValue(input.credential) credentials: settings.credentials,
: input.settings.auth === "sigv4" generation: settings.topP === undefined ? undefined : { topP: settings.topP },
? undefined headers: settings.headers === undefined ? undefined : { ...settings.headers },
: input.settings.apiKey, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
baseURL: input.settings.baseURL, region: settings.region,
credentials: input.settings.credentials, }).model(modelID)
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
region: input.settings.region,
}).model(input.id)
} }
@@ -1,4 +1,4 @@
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js" import type { ProviderAuthOption } from "../route/auth-options.js"
@@ -32,9 +32,7 @@ export const routes = [AnthropicMessages.route]
const auth = (input: ProviderAuthOption<"optional">) => { const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth if ("auth" in input && input.auth) return input.auth
return Auth.remove("authorization").andThen( return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
)
} }
export const configure = (input: Config) => { export const configure = (input: Config) => {
@@ -59,20 +57,20 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined) modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken") throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
...(input.credential baseURL: settings.baseURL,
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key") headers: settings.headers === undefined ? undefined : { ...settings.headers },
: input.settings.authToken === undefined http: settings.body === undefined ? undefined : { body: { ...settings.body } },
? { apiKey: input.settings.apiKey } provider: settings.provider,
: { auth: Auth.bearer(input.settings.authToken) }), providerOptions: settings.providerOptions,
baseURL: input.settings.baseURL, }).model(modelID)
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
} }
export * as AnthropicCompatible from "./anthropic-compatible.js" export * as AnthropicCompatible from "./anthropic-compatible.js"
+15 -17
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js" import type { ProviderAuthOption } from "../route/auth-options.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js" import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -31,11 +31,9 @@ export type Settings = ProviderPackage.Settings &
const auth = (options: ProviderAuthOption<"optional">) => { const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth if ("auth" in options && options.auth) return options.auth
return Auth.remove("authorization").andThen( return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") .orElse(Auth.config("ANTHROPIC_API_KEY"))
.orElse(Auth.config("ANTHROPIC_API_KEY")) .pipe(Auth.header("x-api-key"))
.pipe(Auth.header("x-api-key")),
)
} }
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
@@ -54,17 +52,17 @@ export const configure = (input: Config = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined) modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken") throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
...(input.credential baseURL: settings.baseURL,
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key") headers: settings.headers === undefined ? undefined : { ...settings.headers },
: input.settings.authToken === undefined http: settings.body === undefined ? undefined : { body: { ...settings.body } },
? { apiKey: input.settings.apiKey } providerOptions: settings.providerOptions,
: { auth: Auth.bearer(input.settings.authToken) }), }).model(modelID)
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
} }
+41 -17
View File
@@ -1,7 +1,8 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js" import * as OpenAIResponses from "../protocols/openai-responses.js"
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure") export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization") const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName` // Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly. // (helper builds the URL) or `baseURL` directly.
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses", id: "azure-openai-responses",
provider: id, provider: id,
auth: routeAuth, auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
const url = new URL(value)
return (
url.protocol === "https:" &&
url.hostname.endsWith(".openai.azure.com") &&
url.pathname.endsWith("/openai/v1/responses") &&
url.searchParams.get("api-version") === "v1"
)
},
url: (value) => {
const url = new URL(value)
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
}) })
const chatRoute = OpenAIChat.route.with({ const chatRoute = OpenAIChat.route.with({
@@ -120,29 +146,27 @@ export const provider = {
configure, configure,
} }
const config = (input: ProviderPackage.ModelInput<Settings>): Config => { const config = (settings: Settings): Config => {
const settings = input.settings
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
const resourceName =
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
const common = { const common = {
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
: { apiKey: settings.apiKey }),
apiVersion: settings.apiVersion, apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions, providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls, useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
} }
if (baseURL !== undefined) return { ...common, baseURL } if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (resourceName !== undefined) return { ...common, resourceName } if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL") throw new Error("Azure requires resourceName or baseURL")
} }
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
configure(config(input)).responses(input.id) modelID,
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => settings,
configure(config(input)).chat(input.id) ) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const model = responsesModel export const model = responsesModel
+11 -11
View File
@@ -1,4 +1,4 @@
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js" import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
@@ -68,15 +68,15 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
throw new Error("Google Vertex Chat does not support API keys")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), accessToken: settings.accessToken,
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers },
location: input.settings.location, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
project: input.settings.project, location: settings.location,
providerOptions: input.settings.providerOptions, project: settings.project,
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
} }
@@ -1,10 +1,9 @@
import { Effect, Schema, Struct } from "effect" import { Effect, Schema, Struct } from "effect"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js" import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js" import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js" import { GoogleVertexShared } from "./google-vertex-shared.js"
@@ -14,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const const VERSION = "vertex-2023-10-16" as const
const HEADER_VERSION = "2023-06-01" as const
export const id = ProviderID.make("google-vertex") export const id = ProviderID.make("google-vertex")
@@ -57,7 +57,8 @@ const route = Route.make({
}), }),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`), endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none, auth: Auth.none,
framing: Framing.sse, framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
}) })
export const routes = [route] export const routes = [route]
@@ -100,15 +101,18 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) modelID,
throw new Error("Google Vertex Messages does not support API keys") settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), accessToken: settings.accessToken,
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers },
location: input.settings.location, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
project: input.settings.project, location: settings.location,
providerOptions: input.settings.providerOptions, project: settings.project,
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
} }
@@ -1,4 +1,4 @@
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js" import { ProviderID, type ModelID } from "../schema/index.js"
@@ -70,15 +70,18 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined)) modelID,
throw new Error("Google Vertex Responses does not support API keys") settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), accessToken: settings.accessToken,
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken, baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers },
location: input.settings.location, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
project: input.settings.project, location: settings.location,
providerOptions: input.settings.providerOptions, project: settings.project,
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
} }
@@ -69,8 +69,9 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => { export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined) if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth") throw new Error("Google Vertex accessToken cannot be combined with auth")
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project)) if (input.auth) return input.auth
return Auth.remove("x-goog-api-key").andThen(auth) if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
} }
export * as GoogleVertexShared from "./google-vertex-shared.js" export * as GoogleVertexShared from "./google-vertex-shared.js"
+23 -21
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { Gemini } from "../protocols/gemini.js" import { Gemini } from "../protocols/gemini.js"
import { ProviderShared } from "../protocols/shared.js" import { ProviderShared } from "../protocols/shared.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request) const body = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
parts: (content.parts ?? []).map((part) => {
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
return part
}),
}))
const value = request.providerOptions?.labels const value = request.providerOptions?.labels
const labels = ProviderShared.isRecord(value) const labels = ProviderShared.isRecord(value)
? Object.fromEntries( ? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
) )
: undefined : undefined
return { ...body, labels } return { ...body, contents, labels }
}) })
const protocol = { const protocol = {
@@ -94,10 +104,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
return route.with({ return route.with({
...rest, ...rest,
endpoint: { baseURL: endpoint }, endpoint: { baseURL: endpoint },
auth: auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
apiKey === undefined
? GoogleVertexShared.oauth(input, project)
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
}) })
} }
@@ -114,21 +121,16 @@ export const provider = {
id, id,
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined) if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth") throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({ return configure({
...ProviderPackage.routeDefaults(input.defaults), ...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
...(input.credential baseURL: settings.baseURL,
? input.credential.type === "key" headers: settings.headers === undefined ? undefined : { ...settings.headers },
? { apiKey: input.credential.value } http: settings.body === undefined ? undefined : { body: { ...settings.body } },
: { accessToken: input.credential.accessToken } location: settings.location,
: input.settings.apiKey === undefined project: settings.project,
? { accessToken: input.settings.accessToken } providerOptions: settings.providerOptions,
: { apiKey: input.settings.apiKey }), }).model(modelID)
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
} }
+11 -14
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js" import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js" import type { ProviderAuthOption } from "../route/auth-options.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js" import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js" import { Gemini } from "../protocols/gemini.js"
import { GoogleImages } from "../protocols/google-images.js" import { GoogleImages } from "../protocols/google-images.js"
@@ -28,11 +28,9 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => { const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth if ("auth" in options && options.auth) return options.auth
return Auth.remove("authorization").andThen( return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") .orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY")) .pipe(Auth.header("x-goog-api-key"))
.pipe(Auth.header("x-goog-api-key")),
)
} }
const configuredRoute = (input: Config) => { const configuredRoute = (input: Config) => {
@@ -59,14 +57,13 @@ export const configure = (input: Config = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) => export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential baseURL: settings.baseURL,
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key") headers: settings.headers === undefined ? undefined : { ...settings.headers },
: { apiKey: input.settings.apiKey }), http: settings.body === undefined ? undefined : { body: { ...settings.body } },
baseURL: input.settings.baseURL, providerOptions: settings.providerOptions,
providerOptions: input.settings.providerOptions, }).model(modelID)
}).model(input.id)
export const image = provider.image export const image = provider.image
@@ -1,4 +1,4 @@
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js" import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
@@ -46,11 +46,15 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({ configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers },
provider: input.settings.provider, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: input.settings.providerOptions, provider: settings.provider,
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js" import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -68,14 +68,15 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers === undefined ? undefined : { ...settings.headers },
provider: input.settings.provider, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: input.settings.providerOptions, provider: settings.provider,
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
export const baseten = define(profiles.baseten) export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras) export const cerebras = define(profiles.cerebras)
+27 -6
View File
@@ -1,21 +1,41 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js" import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js" import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js" import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js" export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
export type OpenAIConfigOptions = Options readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
export type OpenAIProviderOptionsInput = OpenAIOptionsInput export type OpenAIProviderOptionsInput = OpenAIOptionsInput
const definedEntries = (input: Record<string, unknown>) =>
Object.entries(input).filter((entry) => entry[1] !== undefined)
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
const result = Object.fromEntries(
definedEntries({
store: options?.store,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
textVerbosity: options?.textVerbosity,
serviceTier: options?.serviceTier,
}),
)
if (Object.keys(result).length === 0) return undefined
return result
}
export const gpt5DefaultOptions = ( export const gpt5DefaultOptions = (
modelID: string, modelID: string,
options: { readonly textVerbosity?: boolean } = {}, options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => { ): ProviderOptions | undefined => {
const id = modelID.toLowerCase() const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return { return openAIProviderOptions({
reasoningEffort: "medium", reasoningEffort: "medium",
reasoningSummary: "auto", reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by // GPT-5 reasoning models are configured stateless (`store: false`) by
@@ -28,13 +48,14 @@ export const gpt5DefaultOptions = (
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat") options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low" ? "low"
: undefined, : undefined,
} })
} }
export const openAIDefaultOptions = ( export const openAIDefaultOptions = (
modelID: string, modelID: string,
options: { readonly textVerbosity?: boolean } = {}, options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options)) ): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>( export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string, modelID: string,
+18 -51
View File
@@ -1,10 +1,10 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js" import type { Route, RouteDefaultsInput } from "../route/client.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js" import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js" import * as OpenAIResponses from "../protocols/openai-responses.js"
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js" import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories // This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization. // and default option normalization.
export type Config = Omit<RouteDefaultsInput, "providerOptions"> & export type Config = RouteDefaultsInput &
OpenAIConfigOptions &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly queryParams?: Record<string, string> readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface ImageGenerationOptions { export interface ImageGenerationOptions {
@@ -57,12 +57,13 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
}, },
}) })
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions { export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL?: string readonly baseURL?: string
readonly organization?: string readonly organization?: string
readonly project?: string readonly project?: string
readonly queryParams?: Readonly<Record<string, string>> readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
} }
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -72,39 +73,6 @@ const defaults = (input: Config) => {
return rest return rest
} }
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
const {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
...rest
} = input
return {
options: {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
},
rest,
}
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({ route.with({
auth: auth(input), auth: auth(input),
@@ -114,8 +82,7 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => { export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input) const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const chatRoute = configuredRoute(OpenAIChat.route, input) const chatRoute = configuredRoute(OpenAIChat.route, input)
const split = splitConfigOptions(defaults(input)) const modelDefaults = defaults(input)
const modelDefaults = { ...split.rest, providerOptions: split.options }
const responses = (id: string | ModelID) => const responses = (id: string | ModelID) =>
responsesRoute responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })) .with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
@@ -146,30 +113,30 @@ export const configure = (input: Config = {}) => {
export const provider = configure() export const provider = configure()
const config = (input: ProviderPackage.ModelInput<Settings>): Config => { const config = (settings: Settings): Config => {
const settings = input.settings
const options = splitConfigOptions(settings).options
const headers = { const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }), ...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }), ...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...input.defaults.headers, ...settings.headers,
} }
return { return {
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers, headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
...options,
} }
} }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => { export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(input)).responses(input.id) return configure(config(settings)).responses(modelID)
} }
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
configure(config(input)).chat(input.id) modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responses = provider.responses export const responses = provider.responses
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
+14 -9
View File
@@ -5,11 +5,11 @@ import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js" import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js" import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js" import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js" import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js" import { isRecord, ProviderShared } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider) export const id = ProviderID.make(profile.provider)
@@ -115,11 +115,12 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails, reasoning_details: reasoningDetails,
} }
}) })
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return { return {
...body, ...body,
messages, messages,
...bodyOptions(request.providerOptions), ...bodyOptions(request.providerOptions),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody } as OpenRouterBody
}), }),
), ),
@@ -191,10 +192,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) => export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({ configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers,
providerOptions: input.settings.providerOptions, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
+18 -10
View File
@@ -5,10 +5,11 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js" import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import * as OpenAIChat from "../protocols/openai-chat.js" import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js" import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
import { XAIResponses } from "../protocols/xai-responses.js"
import { XAIImages } from "../protocols/xai-images.js" import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js" import type { OpenAIOptionsInput } from "./openai-options.js"
import { ProviderPackage } from "../provider-package.js" import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai") export const id = ProviderID.make("xai")
@@ -28,13 +29,19 @@ export interface Settings extends ProviderPackage.Settings {
export type { XAIImageOptions } from "../protocols/xai-images.js" export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({ const responsesRoute = Route.make({
id: "openai-responses", id: "openai-responses",
provider: id, provider: id,
providerMetadataKey: "xai", providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol, protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.httpTransport, transport: OpenResponsesChannel.transport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false } }, defaults: { providerOptions: { store: false } },
}) })
@@ -95,13 +102,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
} }
export const provider = configure() export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) => export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
...ProviderPackage.routeDefaults(input.defaults), apiKey: settings.apiKey,
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }), baseURL: settings.baseURL,
baseURL: input.settings.baseURL, headers: settings.headers,
providerOptions: input.settings.providerOptions, http: settings.body === undefined ? undefined : { body: { ...settings.body } },
}).model(input.id) providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses export const responses = provider.responses
export const chat = provider.chat export const chat = provider.chat
export const image = provider.image export const image = provider.image
-4
View File
@@ -16,7 +16,6 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
LanguageModel, LanguageModel,
LanguageModelLimits,
LLMEvent, LLMEvent,
InvalidProviderOutputReason, InvalidProviderOutputReason,
ProviderID, ProviderID,
@@ -74,7 +73,6 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults { export interface RouteDefaults {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions readonly http?: HttpOptions
@@ -82,7 +80,6 @@ export interface RouteDefaults {
export interface RouteDefaultsInput { export interface RouteDefaultsInput {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -119,7 +116,6 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base, ...base,
...patch, ...patch,
headers, headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions( http: mergeHttpOptions(
+6
View File
@@ -24,4 +24,10 @@ export interface Definition<Frame> {
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ /** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming } export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
})
export * as Framing from "./framing.js" export * as Framing from "./framing.js"
+3
View File
@@ -153,6 +153,9 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String, module: Schema.String,
method: Schema.String, method: Schema.String,
reason: AIErrorReason, reason: AIErrorReason,
// Raw provider payload as a string, so classified failures never lose the
// original error detail even when the pretty message is a summary.
body: Schema.optional(Schema.String),
}) { }) {
override readonly cause = this.reason override readonly cause = this.reason
-17
View File
@@ -114,22 +114,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined return Object.values(result).some((value) => value !== undefined) ? result : undefined
} }
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({ export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions), generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
@@ -139,7 +124,6 @@ export namespace LanguageModelDefaults {
export type Input = export type Input =
| LanguageModelDefaults | LanguageModelDefaults
| { | {
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -149,7 +133,6 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => { export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({ return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions, providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http), http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+17 -55
View File
@@ -81,22 +81,8 @@ OpenAI.configure({
}).responses("gpt-4.1-mini") }).responses("gpt-4.1-mini")
OpenAI.configure({ OpenAI.configure({
generation: { maxTokens: 100 }, generation: { maxTokens: 100 },
store: false, providerOptions: { store: false },
}).responses("gpt-4.1-mini") }).responses("gpt-4.1-mini")
OpenAI.model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: "sk-test" },
defaults: { headers: { "x-test": "value" } },
})
OpenAI.model({
id: "gpt-5",
settings: {
// @ts-expect-error Common request defaults belong under input.defaults.
headers: { "x-test": "value" },
},
defaults: {},
})
// @ts-expect-error OpenAI model selectors only accept model ids. // @ts-expect-error OpenAI model selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {}) OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
@@ -111,7 +97,7 @@ OpenAI.configure({ bogus: true })
OpenAI.configure({ generation: { maxTokens: "many" } }) OpenAI.configure({ generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed. // @ts-expect-error provider-native options remain typed.
OpenAI.configure({ store: "false" }) OpenAI.configure({ providerOptions: { store: "false" } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") }) OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
@@ -159,12 +145,8 @@ Anthropic.configure({
}).model("claude-haiku") }).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids. // @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {}) Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
Anthropic.model({ // @ts-expect-error Anthropic package settings accept only one auth source.
id: "claude-sonnet-4-6", Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
// @ts-expect-error Anthropic package settings accept only one auth source.
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
defaults: {},
})
// @ts-expect-error Enabled Anthropic thinking requires a token budget. // @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } }) Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
// @ts-expect-error Anthropic thinking budgets must be numbers. // @ts-expect-error Anthropic thinking budgets must be numbers.
@@ -180,15 +162,11 @@ AnthropicCompatible.configure({
AnthropicCompatible.configure({ apiKey: "messages-key" }) AnthropicCompatible.configure({ apiKey: "messages-key" })
// @ts-expect-error Anthropic-compatible model selectors only accept model ids. // @ts-expect-error Anthropic-compatible model selectors only accept model ids.
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {}) AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
AnthropicCompatible.model({ // @ts-expect-error Anthropic-compatible package settings accept only one auth source.
id: "compatible-model", AnthropicCompatible.model("compatible-model", {
// @ts-expect-error Anthropic-compatible package settings accept only one auth source. apiKey: "messages-key",
settings: { authToken: "messages-token",
apiKey: "messages-key", baseURL: "https://messages.example.com/v1",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
},
defaults: {},
}) })
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash") Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
@@ -211,23 +189,15 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {}) GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source. // @ts-expect-error Vertex Gemini config accepts only one auth source.
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertex.model({ // @ts-expect-error Vertex Gemini package settings accept only one auth source.
id: "gemini-3.5-flash", GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas") GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model( GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas", "deepseek-ai/deepseek-v3.2-maas",
) )
GoogleVertexChat.model({ // @ts-expect-error Vertex Chat package settings do not accept API keys.
id: "deepseek-ai/deepseek-v3.2-maas", GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Chat package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model( GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas", "deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat model selectors only accept model ids. // @ts-expect-error Vertex Chat model selectors only accept model ids.
@@ -244,12 +214,8 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model( GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning", "xai/grok-4.20-reasoning",
) )
GoogleVertexResponses.model({ // @ts-expect-error Vertex Responses package settings do not accept API keys.
id: "xai/grok-4.20-reasoning", GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Responses package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model( GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
"xai/grok-4.20-reasoning", "xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses model selectors only accept model ids. // @ts-expect-error Vertex Responses model selectors only accept model ids.
@@ -267,12 +233,8 @@ GoogleVertexMessages.configure({
project: "project", project: "project",
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" }, providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
}).model("claude-sonnet-4-6") }).model("claude-sonnet-4-6")
GoogleVertexMessages.model({ // @ts-expect-error Vertex Messages package settings do not accept API keys.
id: "claude-sonnet-4-6", GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Messages package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6") GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model( GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6", "claude-sonnet-4-6",
+5 -6
View File
@@ -270,21 +270,20 @@ describe("request option precedence", () => {
), ),
) )
it.effect("uses model output limits after route limits and before call maxTokens", () => it.effect("uses the Anthropic default before call maxTokens", () =>
Effect.gen(function* () { Effect.gen(function* () {
const route = AnthropicMessages.route.with({ const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" }, endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"), auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
}) })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) const model = route.model({ id: "claude-sonnet-4-5" })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" })) const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest( const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
) )
expect(withoutMaxTokens.body.max_tokens).toBe(64) expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
expect(withMaxTokens.body.max_tokens).toBe(32) expect(withMaxTokens.body.max_tokens).toBe(8_000)
}), }),
) )
}) })
@@ -0,0 +1,31 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:azure",
"provider:azure"
],
"name": "azure/chat-streams-text",
"recordedAt": "2026-08-23T17:21:53.198Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-08-23T17:21:51.036Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-08-23T17:21:51.853Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"name": "google-vertex/streams-text",
"recordedAt": "2026-08-23T17:21:50.112Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"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,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"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,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -0,0 +1,119 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -0,0 +1,129 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -10,6 +10,9 @@ export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n` const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
export const sseNamedEvent = (event: string, data: unknown): string =>
`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}`
/** /**
* Build an SSE body from already-serialized strings (used when the chunk shape * Build an SSE body from already-serialized strings (used when the chunk shape
* itself is part of what's being tested, e.g. malformed chunks). * itself is part of what's being tested, e.g. malformed chunks).
-2
View File
@@ -121,7 +121,6 @@ describe("llm constructors", () => {
const model = chatRoute.model({ const model = chatRoute.model({
id: "kimi-k2", id: "kimi-k2",
defaults: { defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] }, generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false }, providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } }, http: { body: { extra_body: true } },
@@ -130,7 +129,6 @@ describe("llm constructors", () => {
}) })
const request = LLM.request({ model, prompt: "Say hello." }) const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false }) expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
+25
View File
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
]) ])
}) })
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {
expect( expect(
[ [
@@ -98,3 +106,20 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider") expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
}) })
}) })
describe("provider error rawBody classification", () => {
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
})
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "context-overflow" })
})
test("extracts nested codes from the raw payload", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})
})
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini") const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } }) LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
providerOptions: { serviceTier: "premium" },
})
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } }) LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } }) LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } }) LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
+98 -324
View File
@@ -1,72 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM, ProviderPackage } from "@opencode-ai/ai"
import { model } from "@opencode-ai/ai/providers/openai" import { model } from "@opencode-ai/ai/providers/openai"
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
const { headers, body, limits, ...settings } = input
return { id, settings, defaults: { headers, body, limits } }
}
const authHeaders = (
selected: ReturnType<typeof model>,
headers: Record<string, string> = {},
env: Record<string, string> = {},
) =>
Effect.runPromise(
selected.route.auth
.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
})
.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
)
const applyAuth = (
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
headers: Record<string, string> = {},
) => {
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
return Effect.runPromise(
option.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}),
)
}
describe("provider package credential lowering", () => {
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }))
expect(key.authorization).toBe("Bearer provider-key")
expect(oauth.authorization).toBe("Bearer provider-token")
})
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({
apiKey: "provider-key",
})
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
{ type: "oauth", accessToken: "provider-token" },
"x-api-key",
)
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
expect(headers.authorization).toBe("Bearer provider-token")
expect(headers["x-api-key"]).toBeUndefined()
})
})
describe("provider package entrypoints", () => { describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => { test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([ const modules = await Promise.all([
@@ -109,177 +43,49 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1", baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
} }
const openrouter = OpenRouter.model( const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
packageInput("anthropic/claude-sonnet-4", { ...settings,
...settings, providerOptions: { usage: true },
providerOptions: { usage: true }, })
}), const xai = XAI.model("grok-4", {
) ...settings,
const xai = XAI.model( providerOptions: { reasoningEffort: "high" },
packageInput("grok-4", { })
...settings,
providerOptions: { reasoningEffort: "high" },
}),
)
for (const selected of [openrouter, xai]) { for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL) expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers) expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body) expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
} }
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true }) expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false }) expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
}) })
test("maps package settings onto the executable model", () => { test("maps package settings onto the executable model", () => {
const selected = model( const selected = model("gpt-5", {
packageInput("gpt-5", { apiKey: "fixture",
apiKey: "fixture", baseURL: "https://api.openai.test/v1",
baseURL: "https://api.openai.test/v1", headers: { "x-application": "opencode" },
headers: { "x-application": "opencode" }, body: { service_tier: "priority" },
body: { service_tier: "priority" }, unrelatedInheritedSetting: true,
limits: { context: 200_000, output: 64_000 }, })
reasoningEffort: "high",
unrelatedInheritedSetting: true,
}),
)
expect(selected.route.id).toBe("openai-responses") expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("lets provider packages interpret resolved credentials", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const Azure = await import("@opencode-ai/ai/providers/azure")
const Google = await import("@opencode-ai/ai/providers/google")
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const openai = model({
id: "gpt-5",
settings: {},
credential: { type: "oauth", accessToken: "openai-token" },
defaults: {},
})
const anthropicKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "anthropic-key" },
defaults: {},
})
const anthropicOAuth = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "oauth", accessToken: "anthropic-token" },
defaults: {},
})
const anthropicEmptyKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "" },
defaults: {},
})
const azureKey = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "key", value: "azure-key" },
defaults: {},
})
const azureOAuth = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "oauth", accessToken: "azure-token" },
defaults: {},
})
const googleKey = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "key", value: "google-key" },
defaults: {},
})
const googleOAuth = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "oauth", accessToken: "google-token" },
defaults: {},
})
const vertexKey = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: {},
credential: { type: "key", value: "vertex-key" },
defaults: {},
})
const vertexOAuth = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: { project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-token" },
defaults: {},
})
const vertexChatOAuth = GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { apiKey: "configured-key", project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-chat-token" },
defaults: {},
})
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
const anthropicEmptyKeyHeaders = await authHeaders(
anthropicEmptyKey,
{ authorization: "Bearer stale" },
{ ANTHROPIC_API_KEY: "environment-key" },
)
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
expect(anthropicKeyHeaders.authorization).toBeUndefined()
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
expect(azureKeyHeaders.authorization).toBeUndefined()
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
expect(googleKeyHeaders.authorization).toBeUndefined()
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
expect(vertexKeyHeaders.authorization).toBeUndefined()
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
}) })
test("maps OpenAI-compatible Responses settings onto the executable model", async () => { test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses") const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model( const selected = OpenAICompatibleResponses.model("custom-model", {
packageInput("custom-model", { apiKey: "fixture",
apiKey: "fixture", baseURL: "https://responses.example.test/v1",
baseURL: "https://responses.example.test/v1", provider: "example",
provider: "example", headers: { "x-application": "opencode" },
headers: { "x-application": "opencode" }, body: { service_tier: "priority" },
body: { service_tier: "priority" }, providerOptions: { reasoningEffort: "low", store: true },
limits: { context: 200_000, output: 64_000 }, })
providerOptions: { reasoningEffort: "low", store: true },
}),
)
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("openai-compatible-responses") expect(selected.route.id).toBe("openai-compatible-responses")
@@ -289,23 +95,19 @@ describe("provider package entrypoints", () => {
}) })
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true }) expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
}) })
test("maps Anthropic-compatible settings onto the executable model", async () => { test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model( const selected = AnthropicCompatible.model("compatible-model", {
packageInput("compatible-model", { apiKey: "fixture",
apiKey: "fixture", baseURL: "https://messages.example.test/v1",
baseURL: "https://messages.example.test/v1", provider: "example",
provider: "example", headers: { "x-application": "opencode" },
headers: { "x-application": "opencode" }, body: { metadata: { user_id: "user_1" } },
body: { metadata: { user_id: "user_1" } }, providerOptions: { effort: "low" },
limits: { context: 200_000, output: 64_000 }, })
providerOptions: { effort: "low" },
}),
)
expect(String(selected.provider)).toBe("example") expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages") expect(selected.route.id).toBe("anthropic-messages")
@@ -315,18 +117,15 @@ describe("provider package entrypoints", () => {
}) })
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } }) expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" }) expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
}) })
test("maps Anthropic provider options onto the executable model", async () => { test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic") const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model( const selected = Anthropic.model("claude-sonnet-4-6", {
packageInput("claude-sonnet-4-6", { apiKey: "fixture",
apiKey: "fixture", providerOptions: { thinking: { type: "adaptive" } },
providerOptions: { thinking: { type: "adaptive" } }, })
}),
)
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } }) expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
}) })
@@ -334,7 +133,7 @@ describe("provider package entrypoints", () => {
test("requires an Anthropic-compatible base URL at runtime", async () => { test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() => expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]), Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow("Anthropic-compatible providers require a baseURL") ).toThrow("Anthropic-compatible providers require a baseURL")
}) })
@@ -343,28 +142,25 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() => expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [ Reflect.apply(AnthropicCompatible.model, undefined, [
packageInput("compatible-model", { "compatible-model",
{
apiKey: "fixture", apiKey: "fixture",
authToken: "token", authToken: "token",
baseURL: "https://messages.example.test/v1", baseURL: "https://messages.example.test/v1",
}), },
]), ]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken") ).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() => expect(() =>
Reflect.apply(Anthropic.model, undefined, [ Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
]),
).toThrow("Anthropic apiKey cannot be combined with authToken") ).toThrow("Anthropic apiKey cannot be combined with authToken")
}) })
test("maps legacy OpenAI organization and project settings to headers", () => { test("maps legacy OpenAI organization and project settings to headers", () => {
const selected = model( const selected = model("gpt-5", {
packageInput("gpt-5", { apiKey: "fixture",
apiKey: "fixture", organization: "org_123",
organization: "org_123", project: "proj_123",
project: "proj_123", })
}),
)
expect(selected.route.defaults.headers).toMatchObject({ expect(selected.route.defaults.headers).toMatchObject({
"OpenAI-Organization": "org_123", "OpenAI-Organization": "org_123",
@@ -381,37 +177,31 @@ describe("provider package entrypoints", () => {
resourceName: "opencode-test", resourceName: "opencode-test",
headers: { "x-application": "opencode" }, headers: { "x-application": "opencode" },
body: { service_tier: "priority" }, body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
} }
const responses = AzureResponses.model(packageInput("deployment", settings)) const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model(packageInput("deployment", settings)) const chat = AzureChat.model("deployment", settings)
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses") expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses") expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat") expect(chat.route.id).toBe("azure-openai-chat")
}) })
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => { test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure") const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model( const deployment = Azure.model("custom-deployment", {
packageInput("custom-deployment", { apiKey: "fixture",
apiKey: "fixture", resourceName: "opencode-test",
resourceName: "opencode-test", apiVersion: "2025-01-01-preview",
apiVersion: "2025-01-01-preview", useDeploymentBasedUrls: true,
useDeploymentBasedUrls: true, })
}), const gateway = Azure.model("gateway-model", {
) apiKey: "fixture",
const gateway = Azure.model( baseURL: "https://gateway.example/azure/",
packageInput("gateway-model", { })
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
}),
)
expect(deployment.route.endpoint).toMatchObject({ expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment", baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
@@ -423,22 +213,18 @@ describe("provider package entrypoints", () => {
test("maps Google package settings onto the Gemini model", async () => { test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google") const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model( const selected = Google.model("gemini-2.5-flash", {
packageInput("gemini-2.5-flash", { apiKey: "fixture",
apiKey: "fixture", baseURL: "https://generativelanguage.test/v1beta",
baseURL: "https://generativelanguage.test/v1beta", headers: { "x-application": "opencode" },
headers: { "x-application": "opencode" }, body: { safetySettings: [] },
body: { safetySettings: [] }, providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
limits: { context: 1_000_000, output: 65_536 }, })
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
}),
)
expect(selected.route.id).toBe("gemini") expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } }) expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
}) })
@@ -448,35 +234,26 @@ describe("provider package entrypoints", () => {
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat") const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses") const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages") const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const gemini = GoogleVertex.model( const gemini = GoogleVertex.model("gemini-3.5-flash", {
packageInput("gemini-3.5-flash", { apiKey: "fixture",
apiKey: "fixture", headers: { "x-application": "opencode" },
headers: { "x-application": "opencode" }, body: { safetySettings: [] },
body: { safetySettings: [] }, })
limits: { context: 1_000_000, output: 65_536 }, const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
}), accessToken: "fixture",
) location: "global",
const messages = GoogleVertexMessages.model( project: "vertex-project",
packageInput("claude-sonnet-4-6", { })
accessToken: "fixture", const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
location: "global", accessToken: "fixture",
project: "vertex-project", location: "global",
}), project: "vertex-project",
) })
const chat = GoogleVertexChat.model( const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
packageInput("deepseek-ai/deepseek-v3.2-maas", { accessToken: "fixture",
accessToken: "fixture", location: "global",
location: "global", project: "vertex-project",
project: "vertex-project", })
}),
)
const responses = GoogleVertexResponses.model(
packageInput("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model) expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini") expect(gemini.route.id).toBe("google-vertex-gemini")
@@ -484,15 +261,12 @@ describe("provider package entrypoints", () => {
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google") expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] }) expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect( expect(
GoogleVertex.model( GoogleVertex.model("gemini-3.5-flash", {
packageInput("gemini-3.5-flash", { accessToken: "fixture",
accessToken: "fixture", location: "eu",
location: "eu", project: "vertex-project",
project: "vertex-project", }).route.endpoint.baseURL,
}),
).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google") ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(messages.route.id).toBe("google-vertex-messages") expect(messages.route.id).toBe("google-vertex-messages")
expect(messages.route.protocol).toBe("anthropic-messages") expect(messages.route.protocol).toBe("anthropic-messages")
@@ -522,11 +296,8 @@ describe("provider package entrypoints", () => {
const Providers = await import("@opencode-ai/ai/providers") const Providers = await import("@opencode-ai/ai/providers")
expect(() => expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [ Reflect.apply(GoogleVertex.model, undefined, [
packageInput("gemini-3.5-flash", { "gemini-3.5-flash",
accessToken: "token", { accessToken: "token", apiKey: "fixture", project: "vertex-project" },
apiKey: "fixture",
project: "vertex-project",
}),
]), ]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth") ).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [ const configured = Reflect.apply(GoogleVertex.configure, undefined, [
@@ -535,7 +306,8 @@ describe("provider package entrypoints", () => {
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth") expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() => expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [ Reflect.apply(GoogleVertexMessages.model, undefined, [
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }), "claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]), ]),
).toThrow("Google Vertex Messages does not support API keys") ).toThrow("Google Vertex Messages does not support API keys")
expect(() => expect(() =>
@@ -545,7 +317,8 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Messages does not support API keys") ).toThrow("Google Vertex Messages does not support API keys")
expect(() => expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [ Reflect.apply(GoogleVertexChat.model, undefined, [
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }), "deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]), ]),
).toThrow("Google Vertex Chat does not support API keys") ).toThrow("Google Vertex Chat does not support API keys")
expect(() => expect(() =>
@@ -555,7 +328,8 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Chat does not support API keys") ).toThrow("Google Vertex Chat does not support API keys")
expect(() => expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [ Reflect.apply(GoogleVertexResponses.model, undefined, [
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }), "xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]), ]),
).toThrow("Google Vertex Responses does not support API keys") ).toThrow("Google Vertex Responses does not support API keys")
expect(() => expect(() =>
@@ -8,7 +8,7 @@ import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js" import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js" import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js" import { sseEvents, sseNamedEvent, sseRaw } from "../lib/sse.js"
const model = AnthropicMessages.route const model = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") }) .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
@@ -322,11 +322,34 @@ describe("Anthropic Messages route", () => {
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
], ],
stream: true, stream: true,
max_tokens: 4096, max_tokens: 32_000,
}) })
}), }),
) )
it.effect("scrubs outbound tool call IDs without truncating them", () =>
Effect.gen(function* () {
const id = `functions.lookup:1|${"x".repeat(64)}`
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "done" }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
])
expect(scrubbed.length).toBeGreaterThan(64)
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () => it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -640,7 +663,60 @@ describe("Anthropic Messages route", () => {
}), }),
) )
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () => it.effect("ignores unknown named SSE events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseRaw(
sseNamedEvent("message_start", {
type: "message_start",
message: { usage: { input_tokens: 5 } },
}),
sseNamedEvent("proxy.stats", "not json"),
sseNamedEvent("content_block_start", {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
}),
sseNamedEvent("content_block_delta", {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
}),
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
sseNamedEvent("message_delta", {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 1 },
}),
sseNamedEvent("message_stop", { type: "message_stop" }),
sseNamedEvent("proxy.done", "still not json"),
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
Effect.provide( Effect.provide(
@@ -663,6 +739,7 @@ describe("Anthropic Messages route", () => {
type: "message_delta", type: "message_delta",
delta: { stop_reason: "end_turn" }, delta: { stop_reason: "end_turn" },
usage: { usage: {
input_tokens: null,
output_tokens: 8, output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 }, server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" }, output_tokens_details: { terminal_detail: "preserved" },
@@ -682,7 +759,7 @@ describe("Anthropic Messages route", () => {
totalTokens: 15, totalTokens: 15,
providerMetadata: { providerMetadata: {
anthropic: { anthropic: {
input_tokens: 5, input_tokens: null,
cache_read_input_tokens: 2, cache_read_input_tokens: 2,
service_tier: "standard", service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 }, cache_creation: { ephemeral_5m_input_tokens: 1 },
@@ -1339,14 +1416,14 @@ describe("Anthropic Messages route", () => {
Message.assistant([ Message.assistant([
{ {
type: "tool-call", type: "tool-call",
id: "srvtoolu_abc", id: "srvtoolu.abc",
name: "web_search", name: "web_search",
input: { query: "effect 4" }, input: { query: "effect 4" },
providerExecuted: true, providerExecuted: true,
}, },
{ {
type: "tool-result", type: "tool-result",
id: "srvtoolu_abc", id: "srvtoolu.abc",
name: "web_search", name: "web_search",
result: { type: "json", value: [{ url: "https://example.com" }] }, result: { type: "json", value: [{ url: "https://example.com" }] },
providerExecuted: true, providerExecuted: true,
@@ -0,0 +1,93 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { Azure } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
const chatModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).chat("gpt-5.6-luna")
const responsesModel = Azure.configure({
resourceName,
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
}).responses("gpt-5.6-luna")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "azure",
provider: "azure",
requires: ["AZURE_OPENAI_API_KEY"],
})
describe("Azure OpenAI recorded", () => {
recorded.effect("chat streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("responses streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
)
expect(response.text.toLowerCase()).toContain("bonjour")
}),
)
recorded.effect("responses calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("responses continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: responsesModel,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
+559 -41
View File
@@ -139,6 +139,190 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("lowers function call ids for gemini 3 models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_1",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
Effect.gen(function* () {
const messages = [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
]
const legacy = yield* compileRequest(LLM.request({ model, messages }))
const older = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-1.5-flash" }),
messages,
}),
)
expect(legacy.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
},
])
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
}),
)
it.effect("includes function call ids for non-gemini model ids", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemma-3-27b-it" }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () => it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -239,14 +423,18 @@ describe("Gemini route", () => {
functionResponse: { functionResponse: {
name: "read", name: "read",
response: { name: "read", content: "Image read successfully" }, response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
}, },
}, },
], ],
}, },
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
]) ])
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="') expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
}), }),
@@ -279,11 +467,164 @@ describe("Gemini route", () => {
functionResponse: { functionResponse: {
name: "read", name: "read",
response: { name: "read", content: "" }, response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
}, },
}, },
], ],
}, },
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
],
},
])
}),
)
it.effect("nests media inside function responses for gemini 3", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_image",
name: "read",
input: { path: "pixel.png" },
providerMetadata: { google: { thoughtSignature: "sig_1" } },
}),
]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_image",
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
},
],
},
])
}),
)
it.effect("flushes pending media before system update text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.system("Update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
{ text: "<system-update>\nUpdate.\n</system-update>" },
],
},
])
}),
)
it.effect("collects legacy tool media into one turn after merged responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.tool({
id: "call_2",
name: "shot",
result: {
type: "content",
value: [{ type: "text", text: "no image here" }],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
},
{
role: "user",
parts: [
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
],
},
]) ])
}), }),
) )
@@ -605,8 +946,8 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } }, providerMetadata: { google: { thoughtSignature: "thought_sig" } },
}) })
expect(toolCall).toMatchObject({ expect(toolCall).toMatchObject({
id: "tool_0", id: "provider_call",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } }, providerMetadata: { google: { thoughtSignature: "tool_sig" } },
}) })
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"), response.events.findIndex((event) => event.type === "tool-call"),
@@ -614,23 +955,22 @@ describe("Gemini route", () => {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model, model: gemini3,
messages: [ messages: [
Message.assistant([ Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata }, { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({ ToolCallPart.make({
id: "tool_0", id: "provider_call",
name: "lookup", name: "lookup",
input: { query: "weather" }, input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata, providerMetadata: toolCall?.providerMetadata,
}), }),
]), ]),
Message.tool({ Message.tool({
id: "tool_0", id: "provider_call",
name: "lookup", name: "lookup",
result: "done", result: "done",
resultType: "text", resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}), }),
], ],
}), }),
@@ -662,6 +1002,61 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("preserves thoughtSignature on visible text parts", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
expect(delta).toMatchObject({
id: "text-0",
text: "All done.",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
])
}),
)
it.effect("flushes a trailing empty signed text part at block close", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
const end = response.events.find((event) => event.type === "text-end")
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
expect(end).toMatchObject({
id: "text-0",
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
})
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () => it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -679,7 +1074,7 @@ describe("Gemini route", () => {
role: "model", role: "model",
parts: [ parts: [
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } }, functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator", thoughtSignature: "skip_thought_signature_validator",
}, },
], ],
@@ -689,7 +1084,7 @@ describe("Gemini route", () => {
parts: [ parts: [
{ {
functionResponse: { functionResponse: {
id: undefined, id: "tool_0",
name: "lookup", name: "lookup",
response: { name: "lookup", content: "done" }, response: { name: "lookup", content: "done" },
}, },
@@ -725,15 +1120,15 @@ describe("Gemini route", () => {
role: "model", role: "model",
parts: [ parts: [
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } }, functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "parallel_signature", thoughtSignature: "parallel_signature",
}, },
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "news" } }, functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
thoughtSignature: undefined, thoughtSignature: undefined,
}, },
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } }, functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
thoughtSignature: undefined, thoughtSignature: undefined,
}, },
], ],
@@ -761,11 +1156,11 @@ describe("Gemini route", () => {
role: "model", role: "model",
parts: [ parts: [
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } }, functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator", thoughtSignature: "skip_thought_signature_validator",
}, },
{ {
functionCall: { id: undefined, name: "lookup", args: { query: "news" } }, functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
thoughtSignature: "skip_thought_signature_validator", thoughtSignature: "skip_thought_signature_validator",
}, },
], ],
@@ -803,21 +1198,17 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
}) })
expect(response.toolCalls).toEqual([ expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
{ expect(response.toolCalls[0]).toMatchObject({
type: "tool-call", type: "tool-call",
id: "tool_0", name: "lookup",
name: "lookup", input: { query: "weather" },
input: { query: "weather" }, })
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ {
type: "tool-call", type: "tool-call",
id: "tool_0", id: response.toolCalls[0].id,
name: "lookup", name: "lookup",
input: { query: "weather" }, input: { query: "weather" },
providerExecuted: undefined, providerExecuted: undefined,
@@ -860,7 +1251,8 @@ describe("Gemini route", () => {
), ),
) )
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }]) expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
}), }),
) )
@@ -900,7 +1292,7 @@ describe("Gemini route", () => {
content: { content: {
role: "model", role: "model",
parts: [ parts: [
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } }, { functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } }, { functionCall: { name: "lookup", args: { query: "news" } } },
], ],
}, },
@@ -914,16 +1306,19 @@ describe("Gemini route", () => {
}), }),
).pipe(Effect.provide(fixedResponse(body))) ).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([ expect(response.toolCalls[0]).toMatchObject({
{ type: "tool-call",
type: "tool-call", id: "call_0",
id: "tool_0", name: "lookup",
name: "lookup", input: { query: "weather" },
input: { query: "weather" }, })
providerMetadata: { google: { functionCallId: "tool_0" } }, expect(response.toolCalls[1]).toMatchObject({
}, type: "tool-call",
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, name: "lookup",
]) input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({
type: "finish", type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" }, reason: { normalized: "tool-calls", raw: "STOP" },
@@ -931,6 +1326,62 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
id: "dup_call",
providerMetadata: undefined,
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () => it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () { Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe( const length = yield* LLMClient.generate(request).pipe(
@@ -1041,6 +1492,73 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("survives explicit null usage counts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
),
),
),
)
expect(response.text).toBe("Hi")
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
expect(response.usage?.inputTokens).toBeUndefined()
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
expect(response.usage?.reasoningTokens).toBeUndefined()
}),
)
it.effect("survives null candidates, content, parts, and finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: null },
{ candidates: [{ content: { role: "model", parts: null } }] },
{ candidates: [{ content: null, finishReason: null }] },
{
candidates: [
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
],
},
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("treats a null thought flag on a text part as visible output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
],
}),
),
),
)
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
expect(reasoningStart).toBeUndefined()
expect(response.reasoning ?? "").toBe("")
expect(response.text).toBe("Visible")
}),
)
it.effect("fails invalid stream events", () => it.effect("fails invalid stream events", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
import { GoogleVertex } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const model = GoogleVertex.configure({
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
}).model("gemini-3.5-flash")
const lookupWeather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
})
const recorded = recordedTests({
prefix: "google-vertex",
provider: "google-vertex",
protocol: "gemini",
requires: ["GOOGLE_VERTEX_API_KEY"],
})
describe("Google Vertex Gemini recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
)
expect(response.text.toLowerCase()).toContain("hello")
}),
)
recorded.effect("calls a tool", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
tools: [lookupWeather],
}),
)
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
expect(call).toBeDefined()
expect(call?.input).toMatchObject({ city: "Paris" })
}),
)
recorded.effect("continues after a tool result", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
messages: [
Message.user("What is the weather in Paris?"),
Message.assistant([
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
]),
Message.tool({
id: "call_paris_1",
name: "lookup_weather",
result: "18C, light rain",
resultType: "text",
}),
],
tools: [lookupWeather],
}),
)
expect(response.text.length).toBeGreaterThan(0)
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src/index.js" import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js" import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js" import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js" import { compileRequest } from "../../src/route/client.js"
@@ -75,6 +75,53 @@ describe("Google Vertex providers", () => {
}), }),
) )
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
}).model("gemini-3.5-flash"),
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "sunny",
resultType: "text",
providerMetadata: { google: { functionCallId: "provider_call_1" } },
}),
],
}),
)
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
expect(prepared.body.contents).toMatchObject([
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
],
},
])
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () => it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = GoogleVertexMessages.configure({ const model = GoogleVertexMessages.configure({
@@ -96,7 +143,7 @@ describe("Google Vertex providers", () => {
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", "https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
) )
expect(request.headers.get("authorization")).toBe("Bearer vertex-token") expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBeNull() expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
const body = yield* Effect.promise(() => request.json()) const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({ expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16", anthropic_version: "vertex-2023-10-16",
@@ -342,6 +342,7 @@ describe("OpenAI Chat route", () => {
}, },
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) }, { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
], ],
tools: [],
stream: true, stream: true,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
}) })
@@ -664,6 +665,74 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () => it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -73,7 +73,46 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] }, { role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." }, { role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
},
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
{
type: "function_call_output",
call_id: "call_1",
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
},
]) ])
}), }),
) )
@@ -93,7 +132,7 @@ describe("Open Responses-compatible route", () => {
}), }),
) )
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () => it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = configure({ const model = configure({
apiKey: "test-key", apiKey: "test-key",
@@ -113,11 +152,74 @@ describe("Open Responses-compatible route", () => {
) )
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }], input: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
],
}) })
}), }),
) )
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
}),
)
it.effect("reads standard Open Responses options", () => it.effect("reads standard Open Responses options", () =>
Effect.gen(function* () { Effect.gen(function* () {
const model = configure({ const model = configure({
@@ -126,7 +228,12 @@ describe("Open Responses-compatible route", () => {
providerOptions: { providerOptions: {
reasoningEffort: "low", reasoningEffort: "low",
store: true, store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto", truncation: "auto",
serviceTier: "provider-tier",
allowedTools: { toolNames: ["lookup"] }, allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2, maxToolCalls: 2,
parallelToolCalls: false, parallelToolCalls: false,
@@ -136,6 +243,7 @@ describe("Open Responses-compatible route", () => {
LLM.request({ LLM.request({
model, model,
prompt: "Think.", prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}), }),
) )
@@ -143,7 +251,14 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({ expect(prepared.body).toMatchObject({
reasoning: { effort: "low" }, reasoning: { effort: "low" },
store: true, store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto", truncation: "auto",
service_tier: "provider-tier",
tool_choice: { tool_choice: {
type: "allowed_tools", type: "allowed_tools",
mode: "auto", mode: "auto",
@@ -0,0 +1,216 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js" import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js" import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js" import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js" import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import * as ProviderShared from "../../src/protocols/shared.js" import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js" import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js" import { it } from "../lib/effect.js"
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
const continuationDriver = (request: Readonly<Record<string, unknown>>) => { const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request) const message = ProviderShared.encodeJson(request)
return OpenAIResponsesChannel.driver({ return OpenResponsesContinuation.driver({
id: "openai-responses", id: "openai-responses",
name: "OpenAI Responses", name: "OpenAI Responses",
request, request,
@@ -188,17 +188,15 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("omits unsupported semantic service tiers", () => it.effect("passes through provider-defined service tiers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
)
expect(prepared.body).not.toHaveProperty("service_tier") expect(prepared.body.service_tier).toBe("scale")
}), }),
) )
it.effect("flattens top-level object unions in function schemas", () => it.effect("preserves function schemas", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLMRequest.update(request, { LLMRequest.update(request, {
@@ -238,13 +236,22 @@ describe("OpenAI Responses route", () => {
strict: false, strict: false,
parameters: { parameters: {
type: "object", type: "object",
properties: { anyOf: [
path: { type: "string" }, {
reference: { type: "string" }, type: "object",
limit: { type: "integer", maximum: 2000 }, properties: {
resource: { type: "string" }, path: { type: "string" },
}, reference: { anyOf: [{ type: "string" }, { type: "null" }] },
additionalProperties: false, limit: { type: "integer", maximum: 2000 },
},
required: ["path"],
},
{
type: "object",
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
required: ["resource"],
},
],
}, },
}, },
]) ])
@@ -263,7 +270,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] }, { role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." }, { role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
]) ])
}), }),
) )
@@ -394,6 +401,38 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
}),
]),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
)
expect(response.finishReason?.normalized).toBe("stop")
}),
)
it.effect("continues a tool call with only the new tool output", () => it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () { Effect.gen(function* () {
const firstRequest = { const firstRequest = {
@@ -485,7 +524,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("continues store-false reasoning without replaying the output-only item ID", () => it.effect("continues store-false reasoning while retaining the output item ID", () =>
Effect.gen(function* () { Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }] const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput } const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
@@ -515,6 +554,7 @@ describe("OpenAI Responses route", () => {
...firstInput, ...firstInput,
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }], summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted", encrypted_content: "encrypted",
}, },
@@ -690,6 +730,134 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe("Bearer test")
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "grok-4.5",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.finishReason.normalized).toBe("stop")
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "deployment",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
)
}),
)
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
Effect.gen(function* () {
const cases = [
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
apiVersion: "2025-04-01-preview",
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
},
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
useDeploymentBasedUrls: true,
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
},
{
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
"deployment",
),
url: "https://gateway.example/azure/responses",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe(item.url)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () => it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () { Effect.gen(function* () {
const attempts = yield* Ref.make(0) const attempts = yield* Ref.make(0)
@@ -1176,7 +1344,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("uses xAI inline file encoding for PDF tool results", () => it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
@@ -1204,8 +1372,7 @@ describe("OpenAI Responses route", () => {
{ {
type: "input_file", type: "input_file",
filename: "report.pdf", filename: "report.pdf",
file_data: "JVBERi0xLjQ=", file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
mime_type: "application/pdf",
}, },
]) ])
}), }),
@@ -1235,6 +1402,60 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
Message.tool({
id: "call_1",
name: "fetch",
resultType: "content",
result: [
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
])
}),
)
it.effect("lowers tool-result videos as input_video", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
Message.tool({
id: "call_1",
name: "record",
resultType: "content",
result: [
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
])
}),
)
it.effect("prepares the composed native continuation request", () => it.effect("prepares the composed native continuation request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -1284,6 +1505,7 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
promptCacheKey: "session_123", promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [ tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }), ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }), ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
@@ -1293,6 +1515,10 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high", reasoningEffort: "high",
reasoningSummary: "auto", reasoningSummary: "auto",
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
metadata: { environment: "test", tenant: "acme" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled", truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" }, allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4, maxToolCalls: 4,
@@ -1306,6 +1532,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" }) expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled") expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({ expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools", type: "allowed_tools",
@@ -1473,7 +1705,7 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" }, { type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
@@ -1494,6 +1726,108 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects malformed refusal events", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () => it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -1532,33 +1866,39 @@ describe("OpenAI Responses route", () => {
{ {
type: "text", type: "text",
text: "Checking.", text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } }, providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}, },
{ {
type: "text", type: "text",
text: "Finished.", text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } }, providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
}, },
{ {
type: "text", type: "text",
text: "Unclassified.", text: "Unclassified.",
providerMetadata: { openai: { phase: null } }, providerMetadata: { openai: { itemId: "msg_null", phase: null } },
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "message",
id: "msg_commentary",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Checking." }], content: [{ type: "output_text", text: "Checking." }],
phase: "commentary", phase: "commentary",
}, },
{ {
type: "message",
id: "msg_final",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Finished." }], content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer", phase: "final_answer",
}, },
{ {
type: "message",
id: "msg_null",
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }], content: [{ type: "output_text", text: "Unclassified." }],
phase: null, phase: null,
@@ -1652,12 +1992,12 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" }, { type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2" }, { type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" }, { type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" }, { type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
]) ])
}), }),
) )
@@ -1689,7 +2029,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
]) ])
}), }),
) )
@@ -1850,6 +2190,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] }, { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }], summary: [{ type: "summary_text", text: "Checked the previous diff." }],
}, },
@@ -1857,7 +2198,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
], ],
}) })
expect(body.input[1]).not.toHaveProperty("id")
return input.respond( return input.respond(
sseEvents( sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1901,13 +2241,14 @@ describe("OpenAI Responses route", () => {
) )
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }], summary: [{ type: "summary_text", text: "Checked order." }],
}, },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
]) ])
}), }),
) )
@@ -1970,6 +2311,44 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("continues stateless hosted tool results with their text form", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Search."),
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{ role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("continues stateless hosted image generation with the generated image", () => it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () { Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" }) const imageTool = OpenAI.imageGeneration({ action: "edit" })
@@ -2039,6 +2418,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [ summary: [
{ type: "summary_text", text: "First" }, { type: "summary_text", text: "First" },
@@ -2171,6 +2551,50 @@ describe("OpenAI Responses route", () => {
usage, usage,
}, },
]) ])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
[
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
expect(response.finishReason.normalized).toBe("tool-calls")
}), }),
) )
@@ -2237,6 +2661,35 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("retains function call item metadata when output_item.added is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () { Effect.gen(function* () {
const item = { const item = {
@@ -2276,6 +2729,47 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
type: "computer_call",
id: "computer_1",
call_id: "call_1",
status: "completed",
action: { type: "click", x: 100, y: 200 },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
{
type: "tool-call",
id: "computer_1",
name: "computer_use",
input: { type: "click", x: 100, y: 200 },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
{
type: "tool-result",
id: "computer_1",
name: "computer_use",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "computer_1" } },
},
])
}),
)
it.effect("decodes image generation output as image content", () => it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const item = { const item = {
@@ -2397,7 +2891,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("uses xAI inline file encoding for user PDFs", () => it.effect("uses standard inline file encoding for xAI user PDFs", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
@@ -2420,8 +2914,7 @@ describe("OpenAI Responses route", () => {
{ {
type: "input_file", type: "input_file",
filename: "report.pdf", filename: "report.pdf",
file_data: "JVBERi0xLjQ=", file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
mime_type: "application/pdf",
}, },
], ],
}, },
@@ -2454,6 +2947,37 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("lowers remote user media URLs without base64 wrapping", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{
type: "media",
mediaType: "application/pdf",
data: "https://example.com/report.pdf",
filename: "report.pdf",
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
],
},
])
}),
)
it.effect("fails with a typed rate limit for provider error frames", () => it.effect("fails with a typed rate limit for provider error frames", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
@@ -2610,36 +3134,42 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("falls back to a stable default when error is null", () => it.effect("falls back to the raw payload when error is null", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip, Effect.flip,
) )
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" }) expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
}), }),
) )
it.effect("falls back to a stable default when both error and response are absent", () => it.effect("classifies a detail-free error event as a transient provider failure", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))), Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
Effect.flip, Effect.flip,
) )
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}), }),
) )
it.effect("falls back to a stable default when response.failed has no error payload", () => it.effect("keeps the raw response payload when response.failed has no error payload", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))), Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip, Effect.flip,
) )
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" }) expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
}), }),
) )
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
id: "xai", id: "xai",
name: "xAI Grok 4.5", name: "xAI Grok 4.5",
provider: "xai", provider: "xai",
protocol: "openai-responses", protocol: "xai-responses",
requires: "XAI_API_KEY", requires: "XAI_API_KEY",
filename: "verification.pdf", filename: "verification.pdf",
maxTokens: 40, maxTokens: 40,
@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("extends the Open Responses baseline directly", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
}),
)
it.effect("parses xAI reasoning text events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
type: "reasoning",
text: "Considering.",
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "x_search_1",
name: "x_search",
input: { query: "news" },
providerExecuted: true,
})
}),
)
})
+10 -2
View File
@@ -1,5 +1,7 @@
import { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect" import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path" import * as path from "node:path"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js" import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -16,7 +18,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedTestsOptions = RecordedGroupOptions & { type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions readonly options?: HttpRecorder.RecorderOptions
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata, ...metadata,
} }
if (recording) { if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR }) HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
} }
const requestExecutor = RequestExecutor.layer.pipe( const requestExecutor = RequestExecutor.layer.pipe(
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}), }),
), ),
) )
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll( return Layer.mergeAll(
requestExecutor, requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)), LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)), ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
) )
}, },
}) })
+1 -1
View File
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
success: Schema.String, success: Schema.String,
execute: () => Effect.succeed("hello"), execute: () => Effect.succeed("hello"),
}) })
const providerMetadata = { google: { functionCallId: "provider_call" } } const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
const dispatched = yield* ToolRuntime.dispatch( const dispatched = yield* ToolRuntime.dispatch(
{ tool }, { tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),

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