Compare commits

..
Author SHA1 Message Date
Luke Parkerandopencode-agent[bot] 83c531f77d fix(ui): standardize tooltip delay 2026-07-23 00:02:46 +00:00
Dax Raad a817fe5e6c fix(schema): loosen agent color response 2026-07-22 19:22:01 -04:00
James LongandGitHub 381f6c47b4 docs(tui): add generated V2 theme reference (#38396) 2026-07-22 18:30:31 -04:00
James LongandGitHub b6e14b5a74 refactor(tui): finish V2 theme migration (#38383) 2026-07-22 17:56:22 -04:00
36979c9641 test(core): consolidate provider factory coverage (#38390)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:50:33 -05:00
4e067a2014 test(core): remove duplicate patch integration tests (#38389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:19:06 -05:00
+30 5913c1db0b chore: merge dev into v2 (#38377)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
2026-07-22 15:33:14 -05:00
James LongandGitHub 88f572cfce refactor(tui): migrate selection views to V2 theme (#38001) 2026-07-22 16:19:00 -04:00
Aiden ClineandGitHub 2271f9b222 feat(ai): support PDF inputs (#38253) 2026-07-22 13:42:29 -05:00
James LongandGitHub 8a36abd328 feat(tui): add developer debug bar (#38359) 2026-07-22 14:35:28 -04:00
Aiden ClineandGitHub 532292b5f3 fix(core): reject malformed patch hunks (#38188) 2026-07-22 13:11:51 -05:00
Dax Raad 89e3141079 fix(core): preserve cache for session generation 2026-07-22 12:21:40 -04:00
Kit LangtonandGitHub 7a1f9764a2 feat(core): compact durable tool metadata (#38343) 2026-07-22 12:09:48 -04:00
b91dd78ab3 test(core): remove source text assertions (#38342)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-22 11:50:40 -04:00
James LongandGitHub dba5da7c10 fix(tui): restore shell mode styling (#38231) 2026-07-22 11:23:50 -04:00
aea36d7630 fix(tui): prevent duplicate message forks (#38240)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 10:11:58 -05:00
03474816ea test(core): stabilize wellknown event subscriptions (#38331)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 09:40:31 -05:00
Kit LangtonandGitHub 5a9ed4d350 fix: make tool progress live-only (#38217) 2026-07-22 10:40:07 -04:00
Simon KleeandGitHub 4204b9d087 fix(tui): show providers in Mini model search (#38321) 2026-07-22 16:02:17 +02:00
Simon KleeandGitHub 8fad13365b feat(tui): add mini splash setting (#38317)
Fix #38010
2026-07-22 15:46:50 +02:00
Simon KleeandGitHub 5841b04fe7 tui: resolve default mini footer agent (#38315) 2026-07-22 15:42:12 +02:00
Simon KleeandGitHub e12ec8681b fix(tui): render mini markdown in mono mode (#38313) 2026-07-22 15:32:43 +02:00
Simon KleeandGitHub a3e2cc0dcd feat(tui): add mini agent switching (#38287)
Fixes #36121
2026-07-22 11:14:10 +00:00
Simon KleeandGitHub 648183cecb fix(tui): quiet hidden mini footer (#38279) 2026-07-22 12:21:43 +02:00
Simon KleeandGitHub 58d18be590 oc mini v2 (#38278) 2026-07-22 11:50:21 +02:00
Simon KleeandGitHub dc9fd126a0 test(cli): cover interactive command flags (#38273) 2026-07-22 11:16:14 +02:00
Simon KleeandGitHub 794137b33b refactor(tui): narrow mini compatibility surfaces (#38262) 2026-07-22 10:35:35 +02:00
Simon KleeandGitHub 0e08b7330f fix(tui): set mini terminal title on Linux (#38261) 2026-07-22 10:35:07 +02:00
Simon KleeandGitHub ced3d5e02a fix(tui): submit prompt when resuming session (#38260) 2026-07-22 10:34:39 +02:00
Simon KleeandGitHub 6e826f3e22 fix(tui): hide project commands from mini palette (#38259) 2026-07-22 10:34:08 +02:00
Simon KleeandGitHub 8bb1cfaa3b fix: defer catalog validation to session execution (#38258) 2026-07-22 10:32:52 +02:00
Simon KleeandGitHub 691a7d93c8 feat(tui): add compact command to mini (#38251) 2026-07-22 07:03:46 +02:00
Simon KleeandGitHub ca6da05d07 fix(tui): show skill name in mini tool output (#38250) 2026-07-22 07:03:02 +02:00
Aiden ClineandGitHub 23483ea013 feat(ai): support custom reasoning fields (#38227) 2026-07-21 23:42:59 -05:00
Aiden ClineandGitHub 69c05ae3fc feat(codemode): assimilate promise thenables (#38237) 2026-07-21 23:32:02 -05:00
59ad593d9c fix(core): reduce snapshot repository setup (#38162)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-21 23:08:24 -05:00
James LongandGitHub 6b9136e797 fix(tui): inherit elevated tool theme (#38224) 2026-07-21 22:45:21 -04:00
80dc21d8f7 fix(cli): use hosted updater API (#38223)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-22 02:34:48 +00:00
Aiden ClineandGitHub c8a40450e5 feat(codemode): support generator functions (#38172) 2026-07-21 20:36:42 -05:00
Dax Raad e84938b309 fix(core): make session warming observable 2026-07-21 21:30:05 -04:00
Dax Raad c821d49386 feat(updates): add artifact build endpoints 2026-07-21 21:29:42 -04:00
Dax Raad f683eef5f9 feat(core): add session warming 2026-07-21 20:43:26 -04:00
Aiden ClineandGitHub a0a6963beb fix(core): discover Copilot API endpoint (#38184) 2026-07-21 19:28:31 -05:00
Dax Raad da35eac93f fix(sdk): build without legacy sources 2026-07-21 20:13:27 -04:00
Dax Raad c944048bcf feat(updates): add release control service 2026-07-21 19:58:56 -04:00
Dax Raad c2351e308f fix(ci): skip legacy artifacts on v2 2026-07-21 19:11:52 -04:00
DaxandGitHub 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon KleeandGitHub 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
DaxandGitHub 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
+29 77d3289d27 chore: merge dev into v2 (#38182)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
2026-07-21 15:24:47 -05:00
e6607fb58d fix(core): authorize symlinks by lexical path (#38180)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-21 15:13:18 -05:00
128e2550ef fix(core): use shared app node import (#38181)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-21 15:08:53 -05:00
Simon KleeandGitHub e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden ClineandGitHub 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon KleeandGitHub c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden ClineandGitHub 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
DaxandGitHub 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
DaxandGitHub 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon KleeandGitHub dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden ClineandGitHub 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden ClineandGitHub dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon KleeandGitHub 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
023f76f6fc fix(core): refresh model catalog every five minutes (#38142)
Co-authored-by: Victor Navarro <36263538+vimtor@users.noreply.github.com>
2026-07-21 16:34:54 +00:00
DaxandGitHub 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit DashandGitHub cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden ClineandGitHub 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden ClineandGitHub 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
DaxandGitHub 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
cf2c1bf425 refactor(core): break plugin host cycle (#38120)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-07-21 10:35:00 -04:00
DaxandGitHub e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
DaxandGitHub 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon KleeandGitHub dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon KleeandGitHub 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot]GitHubAiden Clineopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
fd97d789ef test(app): stabilize offset observer test (#38044)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-21 00:26:46 -05:00
Aiden ClineandGitHub 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
Aiden ClineandGitHub e770415fd5 fix(core): normalize CRLF patch lines (#38038) 2026-07-20 23:59:10 -05:00
Aiden ClineandGitHub 96dc560833 fix(core): accept padded patch markers (#38036) 2026-07-20 23:44:17 -05:00
Aiden ClineandGitHub eb4ff91c2d fix(core): compose successive patch updates (#38034) 2026-07-20 23:36:48 -05:00
Aiden ClineandGitHub a81c04fd31 feat(codemode): support labeled control flow (#38035) 2026-07-20 23:36:24 -05:00
Aiden ClineandGitHub 63e2054f50 fix(core): improve patch errors (#38016) 2026-07-20 22:49:04 -05:00
Brendan AllanandGitHub 09a38f1984 fix(server): allow authenticated CORS preflight (#38026) 2026-07-20 23:41:27 -04:00
Aiden ClineandGitHub 065b108bba feat(codemode): support JSON callbacks (#38006) 2026-07-20 22:25:14 -05:00
Dax Raad 39fdd67123 fix: remove legacy sdk from ci builds 2026-07-20 22:51:12 -04:00
Dax Raad 44b6938b2a removed packages/opencode 2026-07-20 21:54:22 -04:00
Dax Raad fc7e4cf93e fix(server): preserve v1 auth compatibility 2026-07-20 21:46:23 -04:00
Dax Raad 43c08387f1 refactor: isolate legacy flags 2026-07-20 21:42:02 -04:00
Dax Raad 407ac2d8aa refactor(server): migrate host options 2026-07-20 21:21:36 -04:00
Dax Raad 9445b4d940 refactor(server): inject config options 2026-07-20 21:05:13 -04:00
Dax Raad 0409e6884d refactor(server): inject runtime options 2026-07-20 20:49:25 -04:00
Dax Raad ace77a5cf7 refactor(server): centralize server options 2026-07-20 20:33:17 -04:00
Aiden ClineandGitHub f5a487ffcd feat(codemode): add Set method parity (#38000) 2026-07-20 16:48:35 -05:00
Aiden ClineandGitHub 455b5d3165 fix(core): match dev patch behavior (#37709) 2026-07-20 16:46:11 -05:00
James LongandGitHub a2c4c7a64b refactor(tui): migrate diff viewer to V2 theme (#37999) 2026-07-20 17:29:43 -04:00
e93fa06d05 feat(ai): support image-guided generation (#37998)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 16:21:38 -05:00
fa4ec98e1d feat(ai): add Z.ai image generation (#37780)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:53:21 -05:00
+28 4c9a4309ce chore: merge dev into v2 (#37996)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
2026-07-20 15:20:59 -05:00
James LongandGitHub c0ba62b972 refactor(tui): expose theme colors as properties (#37995) 2026-07-20 16:17:09 -04:00
8cb9361149 feat(ai): add Google image generation (#37781)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:12:22 -05:00
Dax Raad 642950a763 fix(cli): isolate bun updater cache 2026-07-20 16:02:42 -04:00
df1f42d80f feat(ai): add xAI image generation (#37778)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 15:00:31 -05:00
Aiden ClineandGitHub 0c18d06c9f feat(codemode): add groupBy parity (#37992) 2026-07-20 14:58:09 -05:00
17c1e9b083 refactor(ai): infer image provider options (#37948)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-20 14:44:30 -05:00
Dax Raad 0b2e2cbab0 fix(core): ignore empty provider api keys 2026-07-20 15:29:13 -04:00
James LongandGitHub 79d0077f63 feat(tui): add categorical theme hues (#37985) 2026-07-20 15:28:53 -04:00
Dax Raad 0aa4031dd0 refactor(core): inject models options 2026-07-20 15:18:39 -04:00
Dax Raad f971aa0719 refactor(server): inject database options 2026-07-20 15:18:39 -04:00
61b7caa0b5 fix(tui): make migrated selected background transparent (#37967)
Co-authored-by: James Long <longster@gmail.com>
2026-07-20 13:41:18 -04:00
Aiden ClineandGitHub 98c29075b4 feat(codemode): complete date parity (#37960) 2026-07-20 12:13:16 -05:00
Aiden ClineandGitHub 6d50023457 feat(codemode): complete regexp and math parity (#37955) 2026-07-20 11:29:55 -05:00
1550 changed files with 38430 additions and 339096 deletions
+15 -4
View File
@@ -90,11 +90,18 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
- name: Build legacy CLI
if: github.ref_name != 'v2'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- name: Build preview CLI
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -102,6 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: |
@@ -109,6 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -491,11 +500,13 @@ jobs:
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-windows
path: packages/opencode/dist
+5
View File
@@ -97,6 +97,11 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/docs
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
+1 -1
View File
@@ -1,6 +1,6 @@
---
description: translate English to other languages
model: opencode/claude-opus-4-8
model: opencode/gpt-5.6-sol
---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
+1022 -1393
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
if ($app.stage === "production") {
new cloudflare.DnsRecord("TrustCenter", {
zoneId: zoneID,
name: "trust.opencode.ai",
type: "CNAME",
content: "3a69a5bb27875189.vercel-dns-016.com",
proxied: false,
ttl: 60,
})
new cloudflare.DnsRecord("TrustCenterVerification", {
zoneId: zoneID,
name: "opencode.ai",
type: "TXT",
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
ttl: 60,
})
}
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
regionKey: "us",
+66 -33
View File
@@ -8,6 +8,8 @@
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
opencode,
}:
let
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
nodejs
makeWrapper
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
]
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
copyDesktopItems
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
(lib.getLib stdenv.cc.cc)
];
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
name = "ai.opencode.desktop";
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
};
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
postPatch = lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
postPatch =
# NOTE: Relax Bun version check to be a warning instead of an error
''
substituteInPlace packages/script/src/index.ts \
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
''
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
cp -r "${electron.dist}" $HOME/.electron-dist
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase =
''
runHook preInstall
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
mkdir -p $out/Applications
mv dist/mac*/*.app $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
''
+ lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
installPhase = ''
runHook preInstall
''
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
mkdir -p $out/Applications
mv dist/mac*/*.app $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
''
+ lib.optionalString stdenv.hostPlatform.isLinux ''
mkdir -p $out/opt/opencode-desktop
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
install -Dm644 resources/icons/32x32.png \
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/64x64.png \
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128.png \
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/128x128@2x.png \
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
install -Dm644 resources/icons/icon.png \
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
autoPatchelfIgnoreMissingDeps = [
"libc.musl-x86_64.so.1"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
+132 -5
View File
@@ -29,22 +29,149 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image } from "@opencode-ai/ai"
import { Image, ImageInput } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
options: {
n: 2,
size: "1024x1024",
quality: "high", // inferred from the OpenAI image model
outputFormat: "webp",
future_option: true, // unknown native options pass through unchanged
},
})
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
```
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
```ts
const response =
yield *
Image.generate({
model,
prompt: "Combine these product photos into one studio scene",
images: [
ImageInput.bytes(firstBytes, "image/png"),
ImageInput.url("https://example.com/second.webp"),
ImageInput.file("file_123"),
],
options,
http,
})
```
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
`ImageInput` for inpainting:
```ts
yield *
Image.generate({
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
prompt,
images: [ImageInput.bytes(sourceBytes, "image/png")],
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
})
```
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
`InvalidRequest` before network I/O.
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
```ts
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
yield *
Image.generate({
model,
prompt,
options: { quality: "medium" },
http,
})
```
xAI image models use the same request API with xAI-native controls:
```ts
yield *
Image.generate({
model: XAI.configure({ apiKey }).image("any-model-id"),
prompt,
options: {
n: 2,
aspectRatio: "16:9",
resolution: "1k",
responseFormat: "b64_json",
future_option: true,
},
http,
})
```
Google's current Gemini image models use the same direct API:
```ts
import { Google } from "@opencode-ai/ai/providers"
const googleProgram = Effect.gen(function* () {
const response = yield* Image.generate({
model: Google.configure({ apiKey }).image("any-model-id"),
prompt: "A robot tending a rooftop garden",
options: {
aspectRatio: "16:9",
imageSize: "2K",
seed: 42,
thinkingLevel: "HIGH",
includeThoughts: true,
futureOption: true,
},
http,
})
return response.images
})
```
Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
`generateContent` without a local allowlist.
Z.ai image models infer open Z.ai-native options from the selected model:
```ts
yield *
Image.generate({
model: ZAI.configure({ apiKey }).image("any-model-id"),
prompt,
options: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
```
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
remain available.
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
@@ -145,7 +272,7 @@ const gateway = CloudflareAIGateway.configure({
}).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, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
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
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"setup:recording-env": "bun run script/setup-recording-env.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
"build": "tsc -p tsconfig.build.json"
},
"files": [
+7 -3
View File
@@ -1,17 +1,21 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor"
import type { ImageRequest, ImageResponse } from "./image"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
import type { LLMError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<ImageResponse, LLMError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, LLMError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
+90 -40
View File
@@ -1,80 +1,120 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, type Execute as ImageExecute } from "./image-client"
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute {
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
readonly id: string
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
readonly generate: (
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
}
export class ImageModel {
export type ImageOptions = Record<string, unknown>
export class ImageModel<Options extends ImageOptions = ImageOptions> {
declare protected readonly _Options: (options: Options) => Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
readonly route: ImageRoute<Options>
readonly http?: HttpOptions
constructor(input: ImageModel.Input) {
constructor(input: ImageModel.Input<Options>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.defaults = input.defaults
this.http = input.http
}
static make(input: ImageModel.MakeInput) {
return new ImageModel({
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
return new ImageModel<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
defaults: input.defaults,
http: input.http,
})
}
}
export namespace ImageModel {
export interface Input {
export interface Input<Options extends ImageOptions = ImageOptions> {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute
readonly defaults?: ImageModelDefaults
readonly route: ImageRoute<Options>
readonly http?: HttpOptions
}
export interface MakeInput extends Omit<Input, "id" | "provider"> {
export interface MakeInput<Options extends ImageOptions = ImageOptions>
extends Omit<Input<Options>, "id" | "provider"> {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export interface ImageModelDefaults {
readonly providerOptions?: Record<string, Record<string, unknown>>
readonly http?: HttpOptions
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
export const ImageSize = Schema.Struct({
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
}).annotate({ identifier: "Image.Size" })
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
const ImageBytesInput = Schema.Struct({
type: Schema.Literal("bytes"),
data: Schema.Uint8Array,
mediaType: Schema.String,
})
const ImageUrlInput = Schema.Struct({
type: Schema.Literal("url"),
url: Schema.String,
})
const ImageFileIDInput = Schema.Struct({
type: Schema.Literal("file-id"),
id: Schema.String,
})
const ImageFileURIInput = Schema.Struct({
type: Schema.Literal("file-uri"),
uri: Schema.String,
mediaType: Schema.String,
})
export const ImageInputSchema = Schema.Union([
ImageBytesInput,
ImageUrlInput,
ImageFileIDInput,
ImageFileURIInput,
]).pipe(Schema.toTaggedUnion("type"))
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
export const ImageInput = {
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
url: (url: string): ImageInput => ({ type: "url", url }),
file: (id: string): ImageInput => ({ type: "file-id", id }),
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
} as const
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
model: ImageModelSchema,
prompt: Schema.String,
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(ImageSize),
aspectRatio: Schema.optional(Schema.String),
seed: Schema.optional(Schema.Number),
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
images: Schema.optional(Schema.Array(ImageInputSchema)),
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
http: Schema.optional(HttpOptions),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
readonly http?: HttpOptions.Input
}) {
declare protected readonly _ImageRequest: void
}
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
readonly model: ImageModel<Options>
readonly options?: Options
}
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
ConstructorParameters<typeof ImageRequest>[0],
"model" | "options" | "http"
> & {
readonly model: Model
readonly options?: NoInfer<ImageModelOptions<Model>>
readonly http?: HttpOptions.Input
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
@@ -91,24 +131,34 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
}
}
export const request = (input: ImageRequest | ImageRequestInput) => {
export function request<const Model extends object>(
input: ImageRequestInput<Model>,
): ImageRequestFor<ImageModelOptions<Model>>
export function request(input: ImageRequest): ImageRequest
export function request(input: ImageRequest | ImageRequestInput) {
if (input instanceof ImageRequest) return input
return new ImageRequest({
...input,
model: input.model as unknown as ImageModel,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
}
export const generate = (input: ImageRequest | ImageRequestInput) =>
Effect.try({
try: () => request(input),
export function generate<const Model extends object>(
input: ImageRequestInput<Model>,
): Effect.Effect<ImageResponse, LLMError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
export function generate(input: ImageRequest | ImageRequestInput) {
return Effect.try({
try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) =>
new LLMError({
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
}),
}).pipe(Effect.flatMap(ImageClient.generate))
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
}
export const Image = {
request,
+2 -2
View File
@@ -11,8 +11,8 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
export { Image } from "./image"
export { Tool, ToolFailure, toDefinitions } from "./tool"
export { ToolRuntime } from "./tool-runtime"
+36 -29
View File
@@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
@@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({
})
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
const AnthropicDocumentBlock = Schema.Struct({
type: Schema.tag("document"),
source: Schema.Struct({
type: Schema.tag("base64"),
media_type: Schema.Literal("application/pdf"),
data: Schema.String,
}),
cache_control: Schema.optional(AnthropicCacheControl),
})
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
@@ -101,13 +113,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
})
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
// Anthropic accepts either a plain string or an ordered array of text/image
// blocks inside `tool_result.content`. The array form is required when a tool
// returns image bytes (screenshot, image search, etc.) so they can be passed
// to the model as proper image inputs instead of being JSON-stringified into
// the prompt — which silently inflates context by megabytes and can push the
// conversation over the model's token limit.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
// Anthropic accepts either a plain string or an ordered array of text, image, and
// document blocks inside `tool_result.content`. The array form keeps media as native
// model input instead of JSON-stringifying base64 into prompt text.
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
const AnthropicToolResultBlock = Schema.Struct({
type: Schema.tag("tool_result"),
@@ -117,7 +126,12 @@ const AnthropicToolResultBlock = Schema.Struct({
cache_control: Schema.optional(AnthropicCacheControl),
})
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
const AnthropicUserBlock = Schema.Union([
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicDocumentBlock,
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicAssistantBlock = Schema.Union([
AnthropicTextBlock,
@@ -319,12 +333,17 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
})
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia(
"Anthropic Messages",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
if (media.mime === "application/pdf")
return {
type: "document" as const,
source: {
type: "base64" as const,
media_type: "application/pdf" as const,
data: media.base64,
},
} satisfies AnthropicDocumentBlock
return {
type: "image" as const,
source: {
@@ -335,25 +354,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
} satisfies AnthropicImageBlock
})
// Tool results may carry structured text/images. Keep media as provider-native
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
const media = yield* ProviderShared.validateToolFile(
"Anthropic Messages",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: media.mime,
data: media.base64,
},
} satisfies AnthropicImageBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
})
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
@@ -445,7 +452,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* lowerImage(part))
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
@@ -52,6 +52,7 @@ const BedrockToolResultContentItem = Schema.Union([
Schema.Struct({ text: Schema.String }),
Schema.Struct({ json: Schema.Unknown }),
BedrockMedia.ImageBlock,
BedrockMedia.DocumentBlock,
])
const BedrockToolResultBlock = Schema.Struct({
@@ -283,8 +284,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
data: item.uri,
filename: item.name,
})
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)
}
return content
+24 -9
View File
@@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({
data: Schema.String,
}),
})
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.Unknown,
}),
@@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({
const GeminiFunctionResponsePart = Schema.Struct({
functionResponse: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
response: Schema.Unknown,
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
}),
})
@@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
: undefined
}
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
}
const lowerToolCall = (part: ToolCallPart) => ({
functionCall: { name: part.name, args: part.input },
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
})
@@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (part.result.type !== "content") {
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
@@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
}
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = []
for (const item of content) {
if (item.type === "text") continue
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
name: part.name,
response: {
name: part.name,
content: text.join("\n"),
},
parts: media.length > 0 ? media : undefined,
},
})
for (const item of content) {
if (item.type === "text") continue
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
}
}
contents.push({ role: "user", parts })
}
@@ -441,6 +454,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
@@ -453,9 +470,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
}),
)
hasToolCalls = true
+314
View File
@@ -0,0 +1,314 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
GeneratedImage,
ImageModel,
ImageResponse,
type ImageInput,
type ImageRequestFor,
type ImageRoute,
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
type ProviderMetadata,
} from "../schema"
import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input"
const ADAPTER = "google-images"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
export type GoogleImageString<Known extends string> = Known | (string & {})
export type GoogleImageOptions = {
readonly aspectRatio?: GoogleImageString<
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
>
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
readonly seed?: number
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
readonly includeThoughts?: boolean
} & Record<string, unknown>
export type GoogleImageBody = Record<string, unknown> & {
readonly contents: ReadonlyArray<{
readonly role: "user"
readonly parts: ReadonlyArray<Record<string, unknown>>
}>
readonly generationConfig: Record<string, unknown>
}
const GoogleUsage = Schema.StructWithRest(
Schema.Struct({
cachedContentTokenCount: Schema.optional(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number),
promptTokensDetails: Schema.optional(Schema.Unknown),
candidatesTokensDetails: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const GoogleImageResponse = Schema.Struct({
candidates: Schema.optional(
Schema.Array(
Schema.Struct({
index: Schema.optional(Schema.Number),
content: Schema.optional(
Schema.Struct({
parts: Schema.Array(
Schema.Struct({
text: Schema.optional(Schema.String),
thought: Schema.optional(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String),
inlineData: Schema.optional(
Schema.Struct({
mimeType: Schema.String,
data: Schema.String,
}),
),
}),
),
}),
),
finishReason: Schema.optional(Schema.String),
finishMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
citationMetadata: Schema.optional(Schema.Unknown),
groundingMetadata: Schema.optional(Schema.Unknown),
}),
),
),
usageMetadata: Schema.optional(GoogleUsage),
modelVersion: Schema.optional(Schema.String),
responseId: Schema.optional(Schema.String),
promptFeedback: Schema.optional(Schema.Unknown),
})
export interface ModelInput {
readonly id: string
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions
}
const nativeOptions = (options: GoogleImageOptions | undefined) => {
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
const image = {
aspectRatio,
imageSize,
}
const thinkingConfig = {
thinkingLevel,
includeThoughts,
}
return (
mergeJsonRecords(
{
responseModalities: ["IMAGE"],
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
seed,
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
},
native,
) ?? { responseModalities: ["IMAGE"] }
)
}
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new LLMError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
return next.toString()
}
export const model = (input: ModelInput) => {
const route: ImageRoute<GoogleImageOptions> = {
id: ADAPTER,
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
const http = mergeHttpOptions(request.model.http, request.http)
const requestBody = mergeJsonRecords(
{
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
generationConfig: nativeOptions(request.options),
},
http?.body,
) as GoogleImageBody
const text = ProviderShared.encodeJson(requestBody)
const url = applyQuery(
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
http?.query,
)
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url,
body: text,
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
})
const response = yield* execute(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(text, "application/json"),
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
)
const candidates = decoded.candidates ?? []
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
index: candidate.index ?? candidateIndex,
finishReason: candidate.finishReason,
finishMessage: candidate.finishMessage,
safetyRatings: candidate.safetyRatings,
citationMetadata: candidate.citationMetadata,
groundingMetadata: candidate.groundingMetadata,
parts: (candidate.content?.parts ?? []).map((part) =>
part.inlineData === undefined
? {
type: "text",
text: part.text,
thought: part.thought,
thoughtSignature: part.thoughtSignature,
}
: {
type: "inlineData",
mediaType: part.inlineData.mimeType,
thought: part.thought,
thoughtSignature: part.thoughtSignature,
},
),
}))
const encoded = candidates.flatMap((candidate, candidateIndex) =>
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
part.inlineData === undefined || part.thought === true
? []
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
),
)
const images = yield* Effect.forEach(encoded, (item) =>
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
Effect.mapError(() =>
invalidOutput(
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
),
),
Effect.map(
(data) =>
new GeneratedImage({
mediaType: item.inlineData.mimeType,
data,
providerMetadata: {
google: {
candidateIndex: item.candidate.index ?? item.candidateIndex,
partIndex: item.partIndex,
finishReason: item.candidate.finishReason,
safetyRatings: item.candidate.safetyRatings,
citationMetadata: item.candidate.citationMetadata,
groundingMetadata: item.candidate.groundingMetadata,
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
},
},
}),
),
),
)
if (images.length === 0) {
const finishReasons = candidates.flatMap((candidate) =>
candidate.finishReason === undefined ? [] : [candidate.finishReason],
)
return yield* invalidOutput(
`Google Images returned no final images${
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
{
google: {
promptFeedback: decoded.promptFeedback,
candidates: candidateMetadata,
},
},
)
}
const usage = decoded.usageMetadata
const outputTokens =
usage?.candidatesTokenCount === undefined
? undefined
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
return new ImageResponse({
images,
usage:
usage === undefined
? undefined
: new Usage({
inputTokens: usage.promptTokenCount,
outputTokens,
nonCachedInputTokens: ProviderShared.subtractTokens(
usage.promptTokenCount,
usage.cachedContentTokenCount,
),
cacheReadInputTokens: usage.cachedContentTokenCount,
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
providerMetadata: { google: usage },
}),
providerMetadata: {
google: {
modelVersion: decoded.modelVersion,
responseId: decoded.responseId,
promptFeedback: decoded.promptFeedback,
candidates: candidateMetadata,
},
},
})
}),
}
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
}
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
if (image.type === "bytes")
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
if (image.type === "url")
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
Effect.flatMap((decoded) => {
if (decoded === undefined)
return Effect.fail(
ImageInputs.invalid(
ADAPTER,
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
),
)
return Effect.succeed({
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
})
}),
)
return Effect.fail(
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
)
}
export const GoogleImages = {
model,
} as const
+61 -36
View File
@@ -25,6 +25,7 @@ import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -70,15 +71,18 @@ const OpenAIChatMessage = Schema.Union([
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.StructWithRest(
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -145,14 +149,17 @@ const OpenAIChatToolCallDelta = Schema.Struct({
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Unknown),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
@@ -179,7 +186,7 @@ export interface ParserState {
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningField?: string
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
@@ -227,7 +234,7 @@ const openAICompatibleReasoningContent = (native: unknown) =>
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
@@ -259,6 +266,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -285,24 +293,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (reasoning.length === 0) return
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
const reasoningText = (() => {
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
return text
})()
return {
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: reasoningContent,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
@@ -328,9 +337,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
return { messages, images }
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
) {
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
})
@@ -368,7 +380,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
}
flushImages()
return messages
@@ -386,6 +398,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField))
return yield* ProviderShared.invalidRequest(
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
@@ -446,10 +463,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
})
}
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
const reasoningDelta = (
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
configuredField?: string,
) => {
if (!delta) return undefined
const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"])
for (const field of fields) {
if (field === undefined) continue
const text = delta[field]
if (typeof text === "string" && text.length > 0) return { field, text }
}
return undefined
}
const detailText = (details: ReadonlyArray<unknown>) => {
@@ -518,7 +543,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoning = reasoningDelta(delta, state.reasoningField)
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
@@ -635,12 +660,12 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
initial: (request) => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningField: request.model.compatibility?.reasoningField,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
+181 -119
View File
@@ -1,42 +1,50 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
ImageModel,
GeneratedImage,
ImageResponse,
type ImageRequest,
type ImageModelDefaults,
type ImageInput,
type ImageRequestFor,
type ImageRoute,
} from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
import {
InvalidProviderOutputReason,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema"
import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-images"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/images/generations"
export const EDIT_PATH = "/images/edits"
export interface OpenAIImageOptions {
readonly quality?: "auto" | "low" | "medium" | "high"
readonly background?: "auto" | "opaque" | "transparent"
readonly moderation?: "auto" | "low"
readonly outputFormat?: "png" | "jpeg" | "webp"
export type OpenAIImageString<Known extends string> = Known | (string & {})
export type OpenAIImageOptions = {
readonly mask?: ImageInput
readonly n?: number
readonly size?: OpenAIImageString<
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
>
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly moderation?: OpenAIImageString<"auto" | "low">
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
readonly outputCompression?: number
}
} & Record<string, unknown>
const OpenAIImageBody = Schema.Struct({
model: Schema.String,
prompt: Schema.String,
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
})
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
export type OpenAIImageBody = Record<string, unknown> & {
readonly model: string
readonly prompt: string
}
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
@@ -63,26 +71,16 @@ export interface ModelInput {
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly defaults?: ImageModelDefaults
readonly http?: HttpOptions
}
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
...request.model.defaults?.providerOptions?.openai,
...request.providerOptions?.openai,
})
const body = (request: ImageRequest): OpenAIImageBody => {
const options = providerOptions(request)
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
if (!options) return undefined
const { mask: _, outputFormat, outputCompression, ...native } = options
return {
model: request.model.id,
prompt: request.prompt,
n: request.count,
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
quality: options.quality,
background: options.background,
moderation: options.moderation,
output_format: options.outputFormat,
output_compression: options.outputCompression,
output_format: outputFormat,
output_compression: outputCompression,
...native,
}
}
@@ -100,45 +98,93 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_FIELDS = new Set([
"model",
"prompt",
"n",
"size",
"quality",
"background",
"moderation",
"output_format",
"output_compression",
])
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
imageBody: OpenAIImageBody,
overlay: Record<string, unknown> | undefined,
) {
if (!overlay) return imageBody
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
if (reserved.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
)
return mergeJsonRecords(imageBody, overlay) ?? imageBody
})
export const model = (input: ModelInput) => {
const route: ImageRoute = {
const route: ImageRoute<OpenAIImageOptions> = {
id: ADAPTER,
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
if (request.aspectRatio !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
if (request.seed !== undefined)
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
const mask = request.options?.mask
if (mask !== undefined && (request.images?.length ?? 0) === 0)
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
const http = mergeHttpOptions(request.model.http, request.http)
const sourceImages = request.images ?? []
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.succeed(undefined)
})
const multipartMask =
mask === undefined
? undefined
: mask.type === "bytes"
? { data: mask.data, mediaType: mask.mediaType }
: mask.type === "url"
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
: undefined
const useMultipart =
sourceImages.length > 0 &&
multipartImages.every((image) => image !== undefined) &&
(mask === undefined || multipartMask !== undefined)
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
const text = ProviderShared.encodeJson(overlaidBody)
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
if (useMultipart) {
const form = new FormData()
form.append("model", request.model.id)
form.append("prompt", request.prompt)
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
})
multipartImages.forEach((image, index) => {
if (image === undefined) return
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
})
if (multipartMask !== undefined)
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url,
body: "[multipart/form-data]",
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
})
const response = yield* execute(
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
)
return yield* parseResponse(response, request.options, http?.body)
}
const references = sourceImages.map((image) => {
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
if (image.type === "url") return { image_url: image.url }
if (image.type === "file-id") return { file_id: image.id }
return undefined
})
if (references.some((image) => image === undefined))
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
const maskReference =
mask === undefined
? undefined
: mask.type === "bytes"
? { image_url: ImageInputs.dataUrl(mask) }
: mask.type === "url"
? { image_url: mask.url }
: mask.type === "file-id"
? { file_id: mask.id }
: undefined
if (mask !== undefined && maskReference === undefined)
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
prompt: request.prompt,
images: references.length === 0 ? undefined : references,
mask: maskReference,
},
nativeOptions(request.options),
http?.body,
) as OpenAIImageBody
const text = ProviderShared.encodeJson(requestBody)
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
@@ -152,55 +198,71 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
)
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
mediaType: `image/${format}`,
data,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
),
)
if (item.url)
return Effect.succeed(
return yield* parseResponse(response, request.options, http?.body)
}),
}
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
}
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
options: OpenAIImageOptions | undefined,
overlay: Record<string, unknown> | undefined,
) {
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
)
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
const format =
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
mediaType: `image/${format}`,
data: item.url,
data,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
return new ImageResponse({
images,
usage:
decoded.usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { openai: decoded.usage },
}),
providerMetadata: { openai: { outputFormat: format } },
})
}),
}
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
),
)
if (item.url)
return Effect.succeed(
new GeneratedImage({
mediaType: `image/${format}`,
data: item.url,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
return new ImageResponse({
images,
usage:
decoded.usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { openai: decoded.usage },
}),
providerMetadata: { openai: { outputFormat: format } },
})
})
const imageBlob = (data: Uint8Array, mediaType: string) => {
const buffer = new ArrayBuffer(data.byteLength)
new Uint8Array(buffer).set(data)
return new Blob([buffer], { type: mediaType })
}
export const OpenAIImages = {
+70 -33
View File
@@ -11,6 +11,7 @@ import {
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ReasoningPart,
type TextPart,
@@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/responses"
@@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
file_data: Schema.String,
mime_type: Schema.optional(Schema.String),
})
const OpenAIResponsesInputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
const OpenAIResponsesOutputText = Schema.Struct({
@@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// array of content items so tools can return images and files in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
OpenAIResponsesInputText,
OpenAIResponsesInputImage,
OpenAIResponsesInputFile,
])
const OpenAIResponsesFunctionCallOutput = Schema.Union([
Schema.String,
@@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => {
: undefined
}
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") {
const media = yield* ProviderShared.validateMedia(
"OpenAI Responses",
part,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
if (media.mime === "application/pdf") {
// xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
if (provider === "xai")
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.base64,
mime_type: media.mime,
}
return {
type: "input_file" as const,
filename: part.filename ?? "document.pdf",
file_data: media.dataUrl,
}
}
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text/images. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
const media = yield* ProviderShared.validateToolFile(
"OpenAI Responses",
item,
new Set<string>(ProviderShared.IMAGE_MIMES),
)
return { type: "input_image" as const, image_url: media.dataUrl }
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
provider: string,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMedia(part, provider)
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
item: ToolContent,
provider: string,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
provider,
)
})
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
part: ToolResultPart,
provider: string,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
@@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
if (message.role === "user") {
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
})
continue
}
@@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
content: yield* Effect.forEach(content, (item) =>
lowerToolResultContentItem(item, request.model.provider),
),
})
}
if (itemID) hostedToolReferences.add(itemID)
@@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part),
output: yield* lowerToolResultOutput(part, request.model.provider),
})
}
}
+2 -1
View File
@@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
export const PDF_MIMES = ["application/pdf"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
"text/markdown": "md",
} as const satisfies Record<string, DocumentFormat>
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
document: {
format,
name: part.filename ?? `document.${format}`,
name,
source: { bytes },
},
})
@@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
const media = yield* ProviderShared.validateMedia(
"Bedrock Converse",
part,
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
)
return documentBlock(part, documentFormat, media.base64)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
@@ -0,0 +1,34 @@
import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image"
import { InvalidRequestReason, LLMError } from "../../schema"
const invalid = (module: string, message: string) =>
new LLMError({
module,
method: "generate",
reason: new InvalidRequestReason({ message }),
})
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined)
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"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
Effect.map((data) => ({ mediaType: match[1], data })),
)
}
export const invalidImageInput = invalid
export const ImageInputs = {
dataUrl,
decodeDataUrl,
invalid: invalidImageInput,
} as const
+202
View File
@@ -0,0 +1,202 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import {
InvalidProviderOutputReason,
LLMError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema"
import { ProviderShared, optionalNull } from "./shared"
import { ImageInputs } from "./utils/image-input"
const ADAPTER = "xai-images"
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
export const PATH = "/images/generations"
export const EDIT_PATH = "/images/edits"
export type XAIImageString<Known extends string> = Known | (string & {})
export type XAIImageOptions = {
readonly n?: number
readonly aspectRatio?: XAIImageString<
| "1:1"
| "3:4"
| "4:3"
| "9:16"
| "16:9"
| "2:3"
| "3:2"
| "9:19.5"
| "19.5:9"
| "9:20"
| "20:9"
| "1:2"
| "2:1"
| "auto"
>
readonly aspect_ratio?: XAIImageString<
| "1:1"
| "3:4"
| "4:3"
| "9:16"
| "16:9"
| "2:3"
| "3:2"
| "9:19.5"
| "19.5:9"
| "9:20"
| "20:9"
| "1:2"
| "2:1"
| "auto"
>
readonly resolution?: XAIImageString<"1k" | "2k">
readonly responseFormat?: XAIImageString<"url" | "b64_json">
readonly response_format?: XAIImageString<"url" | "b64_json">
} & Record<string, unknown>
type XAIImageBody = Record<string, unknown> & {
readonly model: string
readonly prompt: string
}
const XAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
b64_json: optionalNull(Schema.String),
url: optionalNull(Schema.String),
revised_prompt: optionalNull(Schema.String),
mime_type: optionalNull(Schema.String),
}),
),
usage: Schema.optional(Schema.Unknown),
})
export interface ModelInput {
readonly id: string
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions
}
const nativeOptions = (options: XAIImageOptions | undefined) => {
if (!options) return undefined
const { aspectRatio, responseFormat, ...native } = options
return {
aspect_ratio: aspectRatio,
response_format: responseFormat,
...native,
}
}
const invalidOutput = (message: string) =>
new LLMError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
return next.toString()
}
export const model = (input: ModelInput) => {
const route: ImageRoute<XAIImageOptions> = {
id: ADAPTER,
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
const http = mergeHttpOptions(request.model.http, request.http)
const imageReferences = (request.images ?? []).map((image) => {
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
if (image.type === "url") return { url: image.url, type: "image_url" as const }
if (image.type === "file-id") return { file_id: image.id }
return undefined
})
if (imageReferences.some((image) => image === undefined))
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
prompt: request.prompt,
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
images: imageReferences.length > 1 ? imageReferences : undefined,
},
nativeOptions(request.options),
http?.body,
) as XAIImageBody
const text = ProviderShared.encodeJson(requestBody)
const url = applyQuery(
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
http?.query,
)
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url,
body: text,
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
})
const response = yield* execute(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(text, "application/json"),
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
)
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const mediaType = item.mime_type ?? "application/octet-stream"
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
mediaType,
data,
providerMetadata:
item.revised_prompt === undefined || item.revised_prompt === null
? undefined
: { xai: { revisedPrompt: item.revised_prompt } },
}),
),
)
if (item.url)
return Effect.succeed(
new GeneratedImage({
mediaType,
data: item.url,
providerMetadata:
item.revised_prompt === undefined || item.revised_prompt === null
? undefined
: { xai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
return new ImageResponse({
images,
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
})
}),
}
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
}
export const XAIImages = {
model,
} as const
+132
View File
@@ -0,0 +1,132 @@
import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input"
const ADAPTER = "zai-images"
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
export const PATH = "/images/generations"
export type ZAIImageString<Known extends string> = Known | (string & {})
export type ZAIImageOptions = {
readonly size?: ZAIImageString<
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
>
readonly quality?: ZAIImageString<"hd" | "standard">
readonly userID?: string
} & Record<string, unknown>
type ZAIImageBody = Record<string, unknown> & {
readonly model: string
readonly prompt: string
}
const ZAIImageResponse = Schema.Struct({
created: Schema.optional(Schema.Int),
id: Schema.optional(Schema.String),
request_id: Schema.optional(Schema.String),
data: Schema.Array(Schema.Struct({ url: Schema.String })),
content_filter: Schema.optional(
Schema.Array(
Schema.Struct({
role: Schema.optional(Schema.String),
level: Schema.optional(Schema.Number),
}),
),
),
})
export interface ModelInput {
readonly id: string
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions
}
const nativeOptions = (options: ZAIImageOptions | undefined) => {
if (!options) return undefined
const { userID, ...native } = options
return {
user_id: userID,
...native,
}
}
const invalidOutput = (message: string) =>
new LLMError({
module: ADAPTER,
method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
})
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
if (!query) return url
const next = new URL(url)
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
return next.toString()
}
export const model = (input: ModelInput) => {
const route: ImageRoute<ZAIImageOptions> = {
id: ADAPTER,
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
if ((request.images?.length ?? 0) > 0)
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
const http = mergeHttpOptions(request.model.http, request.http)
const requestBody = mergeJsonRecords(
{ model: request.model.id, prompt: request.prompt },
nativeOptions(request.options),
http?.body,
) as ZAIImageBody
const text = ProviderShared.encodeJson(requestBody)
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url,
body: text,
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
})
const response = yield* execute(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(text, "application/json"),
),
)
const payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
)
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
return new ImageResponse({
images: decoded.data.map(
(item) =>
new GeneratedImage({
mediaType: "application/octet-stream",
data: item.url,
}),
),
providerMetadata: {
zai: {
created: decoded.created,
id: decoded.id,
requestID: decoded.request_id,
contentFilter: decoded.content_filter,
},
},
})
}),
}
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
}
export const ZAIImages = {
model,
} as const
+20 -3
View File
@@ -2,14 +2,20 @@ import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as Gemini from "../protocols/gemini"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
import { Gemini } from "../protocols/gemini"
import { GoogleImages } from "../protocols/google-images"
export type { GoogleImageOptions } from "../protocols/google-images"
export const id = ProviderID.make("google")
export const routes = [Gemini.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
@@ -31,9 +37,18 @@ const configuredRoute = (input: Config) => {
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
const image = (modelID: string | ModelID) =>
GoogleImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
})
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
image,
configure,
}
}
@@ -48,3 +63,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const image = provider.image
+1
View File
@@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenRouter from "./openrouter"
export * as XAI from "./xai"
export * as ZAI from "./zai"
+14 -21
View File
@@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
@@ -22,22 +22,19 @@ export type Config = RouteDefaultsInput &
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
readonly image?: ImageConfig
}
export interface ImageConfig {
readonly providerOptions?: OpenAIImageOptions
}
export interface ImageGenerationOptions {
readonly action?: "auto" | "generate" | "edit"
readonly background?: "auto" | "opaque" | "transparent"
readonly inputFidelity?: "low" | "high"
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly inputFidelity?: OpenAIImageString<"low" | "high">
readonly outputCompression?: number
readonly outputFormat?: "png" | "jpeg" | "webp"
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
readonly partialImages?: number
readonly quality?: "auto" | "low" | "medium" | "high"
readonly size?: string
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly size?: OpenAIImageString<
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
>
}
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
@@ -73,7 +70,7 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
return rest
}
@@ -99,14 +96,10 @@ export const configure = (input: Config = {}) => {
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
defaults: {
providerOptions:
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
},
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
})
return {
+14 -1
View File
@@ -1,9 +1,10 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { XAIImages } from "../protocols/xai-images"
export const id = ProviderID.make("xai")
@@ -12,6 +13,8 @@ export type ModelOptions = RouteDefaultsInput &
readonly baseURL?: string
}
export type { XAIImageOptions } from "../protocols/xai-images"
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
@@ -41,11 +44,20 @@ export const configure = (input: ModelOptions = {}) => {
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
const image = (modelID: string | ModelID) =>
XAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return {
id,
model: responses,
responses,
chat,
image,
configure,
}
}
@@ -54,3 +66,4 @@ export const provider = configure()
export const model = provider.model
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+35
View File
@@ -0,0 +1,35 @@
import { ZAIImages } from "../protocols/zai-images"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { HttpOptions, ProviderID, type ModelID } from "../schema"
export const id = ProviderID.make("zai")
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
export type { ZAIImageOptions } from "../protocols/zai-images"
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
export const configure = (input: Config = {}) => {
const image = (modelID: string | ModelID) =>
ZAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return {
id,
image,
configure,
}
}
export const provider = configure()
export const image = provider.image
+1
View File
@@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSc
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
}) {}
export namespace ModelCompatibility {
+22 -3
View File
@@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
events:
settlement.result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
LLMEvent.toolError({
id: call.id,
name: call.name,
message: String(settlement.result.value),
error,
providerMetadata: call.providerMetadata,
}),
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
providerMetadata: call.providerMetadata,
}),
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
: [
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: settlement.result,
output: settlement.output,
providerMetadata: call.providerMetadata,
}),
],
}
}
+18 -21
View File
@@ -1,7 +1,6 @@
import { Config } from "effect"
import type { Auth } from "../src/route/auth"
import { Auth } from "../src/route"
import type { ModelFactory } from "../src/route/auth-options"
import { Auth as RuntimeAuth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic"
@@ -28,7 +27,7 @@ type Model = {
readonly id: string
}
declare const auth: Auth
declare const auth: Auth.Definition
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
const configApiKey = Config.redacted("OPENAI_API_KEY")
@@ -76,9 +75,9 @@ OpenAI.responses("gpt-4.1-mini")
OpenAI.configure({}).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).responses("gpt-4.1-mini")
OpenAI.configure({
auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
auth: Auth.headers({ authorization: "Bearer gateway" }),
baseURL: "https://gateway.example.com/v1",
}).responses("gpt-4.1-mini")
OpenAI.configure({
@@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } })
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
OpenAI.chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).chat("gpt-4.1-mini")
// @ts-expect-error OpenAI chat selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
Azure.configure()
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
// @ts-expect-error Azure model selectors only accept deployment ids.
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
// @ts-expect-error Azure chat model selectors only accept deployment ids.
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
@@ -165,7 +164,7 @@ Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source.
@@ -174,7 +173,7 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
)
// @ts-expect-error Vertex Chat package settings do not accept API keys.
@@ -187,12 +186,12 @@ GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).
GoogleVertexChat.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Chat config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
auth: Auth.bearer("vertex-token"),
project: "project",
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning",
)
// @ts-expect-error Vertex Responses package settings do not accept API keys.
@@ -205,16 +204,14 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
GoogleVertexResponses.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Responses config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
auth: Auth.bearer("vertex-token"),
project: "project",
})
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.configure({ auth: RuntimeAuth.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(
"claude-sonnet-4-6",
// @ts-expect-error Vertex Messages model selectors only accept model ids.
@@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project"
GoogleVertexMessages.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Messages config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
auth: Auth.bearer("vertex-token"),
project: "project",
})
+3 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import {
@@ -11,12 +11,7 @@ import {
XAI,
} from "@opencode-ai/ai/providers"
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
import {
OpenAIChat,
OpenAICompatibleChat,
OpenAICompatibleResponses,
OpenAIResponses,
} from "@opencode-ai/ai/protocols"
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
describe("public exports", () => {
@@ -24,6 +19,7 @@ describe("public exports", () => {
expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction()
expect(LLMClient.layer).toBeDefined()
expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make)
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

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,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -0,0 +1,36 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,53 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n"
}
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\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
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,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
"name": "zai-images/generates-an-image",
"recordedAt": "2026-07-19T16:03:55.761Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/images/generations",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json; charset=UTF-8"
},
"body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
}
}
]
}
+502 -19
View File
@@ -1,8 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../src"
import { OpenAI } from "../src/providers"
import { Image, ImageClient, ImageInput } from "../src"
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
@@ -17,13 +17,20 @@ describe("Image", () => {
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
}).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: 2,
size: { width: 1024, height: 1024 },
providerOptions: {
openai: { quality: "high", outputFormat: "webp" },
options: {
n: 2,
size: "2048x2048",
quality: "future-quality",
outputFormat: "jpeg",
output_format: "avif",
outputCompression: 30,
output_compression: 40,
background: "opaque",
native_default: true,
future_option: true,
},
http: {
body: { request_metadata: "value" },
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
headers: { "x-request": "yes" },
query: { trace: "1" },
},
@@ -49,9 +56,13 @@ describe("Image", () => {
model: "gpt-image-2",
prompt: "A robot tending a rooftop garden",
n: 2,
size: "1024x1024",
quality: "high",
size: "2048x2048",
quality: "future-quality",
background: "opaque",
output_format: "webp",
output_compression: 50,
native_default: true,
future_option: "http",
deployment: "test",
request_metadata: "value",
})
@@ -71,23 +82,495 @@ describe("Image", () => {
),
)
it.effect("rejects invalid common and OpenAI image options locally", () =>
it.effect("preserves native snake_case and unknown request options", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
count: -1,
size: { width: -1, height: 0.5 },
providerOptions: { openai: { outputCompression: 101 } },
model: OpenAI.configure({
apiKey: "test",
baseURL: "https://api.openai.test/v1",
}).image("future-image-model"),
prompt: "A lighthouse in fog",
options: {
outputFormat: "jpeg",
output_format: "avif",
outputCompression: 30,
output_compression: 40,
provider_future_option: { enabled: true },
},
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.tap((response) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidRequest")
expect(response.image?.mediaType).toBe("image/avif")
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toEqual({
model: "future-image-model",
prompt: "A lighthouse in fog",
output_format: "avif",
output_compression: 40,
provider_future_option: { enabled: true },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
prompt: "Combine these images",
images: [
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
ImageInput.url("data:image/jpeg;base64,BAUG"),
],
options: {
mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
quality: "high",
future_option: true,
},
http: {
body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
headers: { "content-type": "application/json" },
},
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://api.openai.test/v1/images/edits")
expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
expect(input.text).toContain('name="mask"')
expect(input.text).toContain('name="quality"\r\n\r\nlow')
expect(input.text).not.toContain("corrupt")
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
headers: { "content-type": "application/json" },
})
}),
),
),
),
),
),
)
it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
Image.generate({
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
prompt: "Combine these images",
images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
options: { mask: ImageInput.file("file_mask") },
http: { body: { future_option: true } },
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toEqual({
model: "future-model",
prompt: "Combine these images",
images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
mask: { file_id: "file_mask" },
future_option: true,
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("routes ordered xAI image inputs through JSON edits", () =>
Image.generate({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
prompt: "Combine these images",
images: [
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
ImageInput.url("https://example.test/source.jpg"),
ImageInput.file("file_123"),
],
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toEqual({
model: "future-model",
prompt: "Combine these images",
images: [
{ url: "data:image/png;base64,AQID", type: "image_url" },
{ url: "https://example.test/source.jpg", type: "image_url" },
{ file_id: "file_123" },
],
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("uses xAI's singular image field for one input", () =>
Image.generate({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
prompt: "Edit this image",
images: [ImageInput.file("file_123")],
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toEqual({
model: "future-model",
prompt: "Edit this image",
image: { file_id: "file_123" },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
Image.generate({
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
prompt: "Combine these images",
images: [
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
ImageInput.url("data:image/jpeg;base64,BAUG"),
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
],
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text).contents[0].parts).toEqual([
{ text: "Combine these images" },
{ inlineData: { mimeType: "image/png", data: "AQID" } },
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
{
fileData: {
mimeType: "image/webp",
fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
},
},
])
return Effect.succeed(
input.respond(
JSON.stringify({
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
}),
{ headers: { "content-type": "application/json" } },
),
)
}),
),
),
),
),
)
it.effect("rejects unsupported provider inputs before sending", () =>
Effect.gen(function* () {
const cases = [
Image.generate({
model: Google.configure({ apiKey: "test" }).image("model"),
prompt: "edit",
images: [ImageInput.url("https://example.test/image.png")],
}),
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
prompt: "edit",
images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
}),
]
yield* Effect.forEach(cases, (program) =>
program.pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
),
)
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
),
),
),
)
it.effect("generates images through the Google generateContent API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: Google.configure({
apiKey: "test",
baseURL: "https://generativelanguage.test/v1beta/",
headers: { "x-default": "yes" },
http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
}).image("any-model-id"),
prompt: "A robot tending a rooftop garden",
options: {
aspectRatio: "16:9",
imageSize: "2K",
seed: 42,
thinkingLevel: "HIGH",
includeThoughts: true,
futureOption: true,
imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
},
http: {
body: {
safetySettings: [],
generationConfig: {
imageConfig: { aspectRatio: "3:2", httpImageOption: true },
thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
futureOption: "http",
httpOption: true,
},
},
headers: { "x-request": "yes" },
query: { trace: "1" },
},
})
expect(response.images).toHaveLength(3)
expect(response.images.map((image) => image.data)).toEqual([
Uint8Array.from([1, 2, 3]),
Uint8Array.from([4, 5, 6]),
Uint8Array.from([7, 8, 9]),
])
expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
expect(response.images[1].providerMetadata).toMatchObject({
google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
})
expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
expect(response.usage?.inputTokens).toBe(5)
expect(response.usage?.outputTokens).toBe(10)
expect(response.usage?.reasoningTokens).toBe(3)
expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
expect(response.providerMetadata).toEqual({
google: {
modelVersion: "gemini-3.1-flash-image",
responseId: "response-1",
promptFeedback: undefined,
candidates: [
{
index: 0,
finishReason: "STOP",
finishMessage: undefined,
safetyRatings: [{ category: "safe" }],
citationMetadata: undefined,
groundingMetadata: undefined,
parts: [
{
type: "inlineData",
mediaType: "image/png",
thought: undefined,
thoughtSignature: "signature-1",
},
{ type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
{
type: "inlineData",
mediaType: "image/png",
thought: true,
thoughtSignature: "draft-signature",
},
{
type: "inlineData",
mediaType: "image/jpeg",
thought: undefined,
thoughtSignature: undefined,
},
],
},
{
index: 7,
finishReason: undefined,
finishMessage: undefined,
safetyRatings: undefined,
citationMetadata: undefined,
groundingMetadata: undefined,
parts: [
{
type: "inlineData",
mediaType: "image/webp",
thought: undefined,
thoughtSignature: undefined,
},
],
},
],
},
})
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
)
expect(request.headers.get("x-goog-api-key")).toBe("test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig: {
aspectRatio: "3:2",
imageSize: "2K",
nativeImageOption: true,
httpImageOption: true,
},
seed: 42,
thinkingConfig: {
thinkingLevel: "LOW",
includeThoughts: false,
nativeThinkingOption: true,
httpThinkingOption: true,
},
futureOption: "http",
httpOption: true,
},
labels: { deployment: "test" },
safetySettings: [],
})
return input.respond(
JSON.stringify({
candidates: [
{
content: {
parts: [
{
inlineData: { mimeType: "image/png", data: "AQID" },
thoughtSignature: "signature-1",
},
{ text: "planning", thought: true, thoughtSignature: "text-signature" },
{
inlineData: { mimeType: "image/png", data: "CgsM" },
thought: true,
thoughtSignature: "draft-signature",
},
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
],
},
finishReason: "STOP",
safetyRatings: [{ category: "safe" }],
},
{
index: 7,
content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
},
],
usageMetadata: {
promptTokenCount: 5,
candidatesTokenCount: 7,
thoughtsTokenCount: 3,
totalTokenCount: 15,
serviceTier: "STANDARD",
},
modelVersion: "gemini-3.1-flash-image",
responseId: "response-1",
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("includes Google diagnostics when no final image is returned", () =>
Image.generate({
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
"gemini-3.1-flash-image",
),
prompt: "A robot tending a rooftop garden",
}).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
expect(error.reason.providerMetadata).toEqual({
google: {
promptFeedback: { blockReason: "SAFETY" },
candidates: [
{
index: 0,
finishReason: "IMAGE_SAFETY",
finishMessage: "The generated image was blocked by safety filters.",
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
citationMetadata: undefined,
groundingMetadata: undefined,
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
},
],
},
})
}),
),
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(
JSON.stringify({
candidates: [
{
content: { parts: [{ text: "blocked", thought: false }] },
finishReason: "IMAGE_SAFETY",
finishMessage: "The generated image was blocked by safety filters.",
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
},
],
promptFeedback: { blockReason: "SAFETY" },
}),
{ headers: { "content-type": "application/json" } },
),
),
),
),
),
),
),
+161
View File
@@ -0,0 +1,161 @@
import {
Image,
ImageInput,
ImageModel,
type ImageModelOptions,
type ImageOptions,
type ImageRequestFor,
type ImageRoute,
} from "../src"
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
type GoogleLikeOptions = {
readonly aspectRatio?: "1:1" | "16:9"
readonly imageSize?: "1K" | "2K"
} & Record<string, unknown>
declare const route: ImageRoute<GoogleLikeOptions>
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
// @ts-expect-error Extracted model options retain known provider fields.
const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
void invalidGoogleOptions
Image.generate({
model: google,
prompt: "A lighthouse",
images: [
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
ImageInput.url("data:image/jpeg;base64,AQID"),
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/example", "image/webp"),
],
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
})
const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id")
Image.generate({
model: googleProvider,
prompt: "A lighthouse",
options: {
aspectRatio: "16:9",
imageSize: "2K",
seed: 42,
thinkingLevel: "HIGH",
includeThoughts: true,
futureOption: true,
},
})
Image.generate({
model: googleProvider,
prompt: "A lighthouse",
options: { aspectRatio: "future-ratio", imageSize: "8K", thinkingLevel: "FUTURE" },
})
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
Google.configure({ image: { providerOptions: { imageSize: "2K" } } })
// @ts-expect-error Known Google string options retain their value kind.
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { imageSize: 2 } })
// @ts-expect-error Known Google numeric options retain their value kind.
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed: "42" } })
// @ts-expect-error Known Google boolean options retain their value kind.
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
const openai = OpenAI.image("gpt-image-2")
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
OpenAI.configure({ image: { options: { quality: "medium" } } })
const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
void futureOpenAIOptions
Image.generate({
model: openai,
prompt: "A lighthouse",
images: [ImageInput.url("https://example.com/source.png"), ImageInput.file("file_123")],
options: {
mask: ImageInput.bytes(Uint8Array.from([1]), "image/png"),
quality: "hd",
outputFormat: "webp",
size: "2048x2048",
future_option: true,
},
})
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
// @ts-expect-error Known OpenAI string options retain their value kind.
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
// @ts-expect-error Known OpenAI numeric options retain their value kind.
Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
// @ts-expect-error Hosted image generation numeric options retain their value kind.
OpenAI.imageGeneration({ partialImages: "2" })
// @ts-expect-error Known Google-like options are inferred from the selected model.
Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
const xai = XAI.configure({ apiKey: "test" }).image("any-model-id")
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
XAI.configure({ image: { options: { resolution: "1k" } } })
Image.generate({
model: xai,
prompt: "A lighthouse",
images: [ImageInput.url("data:image/png;base64,AQID"), ImageInput.file("file_123")],
options: {
n: 2,
aspectRatio: "future-ratio",
resolution: "future-resolution",
responseFormat: "future-format",
future_option: true,
},
})
Image.generate({
model: xai,
prompt: "A lighthouse",
options: { aspect_ratio: "16:9", response_format: "b64_json", native_future_option: true },
})
// @ts-expect-error Known xAI numeric options retain their value kind.
Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
// @ts-expect-error Known xAI string options retain their value kind.
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
ZAI.configure({ image: { options: { quality: "hd" } } })
Image.generate({
model: zai,
prompt: "A lighthouse",
options: { quality: "future-quality", userID: "user-123", future_option: true },
})
Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
// @ts-expect-error Known Z.ai string options retain their value kind.
Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
// @ts-expect-error Known Z.ai user IDs retain their value kind.
Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
declare const generic: ImageModel<ImageOptions>
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
const explicitImageInput: ImageInput = ImageInput.url("https://example.com/image.png")
void explicitImageInput
// @ts-expect-error Raw strings are ambiguous and are not image inputs.
Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] })
// @ts-expect-error Byte image inputs require an explicit MIME type.
Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] })
// @ts-expect-error File URIs require an explicit MIME type for Gemini fileData.
Image.generate({ model: google, prompt: "A lighthouse", images: [{ type: "file-uri", uri: "files/123" }] })
const request = Image.request({
model: google,
prompt: "A lighthouse",
options: { aspectRatio: "1:1", futureOption: true },
})
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
void typedRequest
// @ts-expect-error Image requests no longer expose a common count option.
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
// @ts-expect-error Image requests no longer expose a common size option.
Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
// @ts-expect-error Image requests no longer expose a common aspectRatio option.
Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
// @ts-expect-error Image requests no longer expose a common seed option.
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
// @ts-expect-error Image requests do not expose metadata.
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
// @ts-expect-error Masks are provider options, not a common image request field.
Image.generate({ model: openai, prompt: "A lighthouse", mask: ImageInput.url("https://example.com/mask.png") })
+29
View File
@@ -0,0 +1,29 @@
export const dimensions = (data: Uint8Array) => {
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
return {
width: readUint32(data, 16),
height: readUint32(data, 20),
}
if (data[0] === 0xff && data[1] === 0xd8) {
for (let offset = 2; offset + 8 < data.length; ) {
if (data[offset] !== 0xff) {
offset++
continue
}
const marker = data[offset + 1]
if (
marker !== undefined &&
[0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)
)
return {
width: (data[offset + 7] << 8) | data[offset + 8],
height: (data[offset + 5] << 8) | data[offset + 6],
}
offset += 2 + ((data[offset + 2] << 8) | data[offset + 3])
}
}
throw new Error("Unsupported image fixture format")
}
const readUint32 = (data: Uint8Array, offset: number) =>
((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0
+6 -1
View File
@@ -59,7 +59,12 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...request.messages,
Message.assistant(state.assistantContent),
...dispatched.map(([call, dispatched]) =>
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
Message.tool({
id: call.id,
name: call.name,
result: dispatched.result,
providerMetadata: call.providerMetadata,
}),
),
],
})
@@ -235,9 +235,9 @@ describe("Anthropic Messages route", () => {
}),
)
// Regression: screenshot/read tool results must stay structured so base64
// image data is not JSON-stringified into `tool_result.content`.
it.effect("lowers image tool-result content as structured image blocks", () =>
// Regression: read tool results must stay structured so base64 media data is
// not JSON-stringified into `tool_result.content`.
it.effect("lowers media tool-result content as structured blocks", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
@@ -253,6 +253,7 @@ describe("Anthropic Messages route", () => {
result: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
}),
],
@@ -263,6 +264,7 @@ describe("Anthropic Messages route", () => {
expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: "Image read successfully" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
])
}),
)
@@ -292,7 +294,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -756,7 +758,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("continues a conversation with user image content", () =>
it.effect("continues a conversation with user media content", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
@@ -766,6 +768,7 @@ describe("Anthropic Messages route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
@@ -781,6 +784,7 @@ describe("Anthropic Messages route", () => {
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
],
@@ -549,10 +549,12 @@ describe("Bedrock Converse route", () => {
LLM.request({
id: "req_doc",
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Summarize these documents." },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
]),
],
}),
@@ -563,10 +565,9 @@ describe("Bedrock Converse route", () => {
{
role: "user",
content: [
// Filename round-trips when supplied.
{ text: "Summarize these documents." },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
// Falls back to a stable placeholder when filename is missing.
{ document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
@@ -574,6 +575,96 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
}),
)
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
cache: "none",
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "UERGREFUQQ==",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
},
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report",
},
],
},
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "call_1",
status: "success",
content: [
{ text: "Read successfully" },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
},
],
},
])
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
+64 -11
View File
@@ -70,6 +70,7 @@ describe("Gemini route", () => {
Message.user([
{ type: "text", text: "What is in this image?" },
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
]),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
@@ -81,7 +82,11 @@ describe("Gemini route", () => {
contents: [
{
role: "user",
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
parts: [
{ text: "What is in this image?" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
{
role: "model",
@@ -90,7 +95,12 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
{
functionResponse: {
name: "lookup",
response: { name: "lookup", content: '{"forecast":"sunny"}' },
},
},
],
},
],
@@ -110,7 +120,7 @@ describe("Gemini route", () => {
}),
)
it.effect("continues image tool results as inline vision input without base64 text", () =>
it.effect("continues media tool results as inline model input without base64 text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
@@ -125,6 +135,7 @@ describe("Gemini route", () => {
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
],
},
}),
@@ -141,9 +152,12 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
],
},
])
@@ -174,8 +188,13 @@ describe("Gemini route", () => {
{
role: "user",
parts: [
{ functionResponse: { name: "read", response: { name: "read", content: "" } } },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
{
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
])
@@ -372,7 +391,10 @@ describe("Gemini route", () => {
parts: [
{ text: "thinking", thought: true },
{ text: "", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
finishReason: "STOP",
@@ -398,7 +420,10 @@ describe("Gemini route", () => {
id: "reasoning-0",
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
expect(toolCall).toMatchObject({
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
)
@@ -416,6 +441,13 @@ describe("Gemini route", () => {
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
providerMetadata: toolCall?.providerMetadata,
}),
],
}),
)
@@ -424,7 +456,22 @@ describe("Gemini route", () => {
role: "model",
parts: [
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
{
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "provider_call",
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
@@ -498,7 +545,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -513,7 +560,13 @@ describe("Gemini route", () => {
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
@@ -0,0 +1,56 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image, ImageInput } from "../../src"
import { Google } from "../../src/providers"
import { dimensions } from "../lib/image"
import { recordedTests } from "../recorded-test"
const model = Google.configure({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
}).image("gemini-3.1-flash-image")
const recorded = recordedTests({
prefix: "google-images",
provider: "google",
protocol: "google-images",
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
})
describe("Google Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat blue circle centered on a plain white background.",
options: { aspectRatio: "1:1" },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toMatch(/^image\//)
expect(response.image?.data).toBeInstanceOf(Uint8Array)
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
recorded.effect("edits an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt:
"Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.",
images: [
ImageInput.bytes(
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
"image/jpeg",
),
],
options: { aspectRatio: "1:1" },
})
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toBeInstanceOf(Uint8Array)
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned Google image bytes")
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
}),
)
})
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
const cases = [
{
name: "OpenRouter",
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
model: Model.update(
OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
{ compatibility: { reasoningField: "reasoning" } },
),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
model: OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
model: Model.update(
OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
{ compatibility: { reasoningField: "reasoning" } },
),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
@@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning" } },
},
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
{ role: "assistant", content: "Done", vendor_reasoning: "" },
])
}),
)
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
}),
).pipe(Effect.flip)
expect(error.message).toContain("reserved field content")
}),
)
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
@@ -570,6 +609,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { vendor_reasoning: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "vendor_reasoning" },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: custom, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
])
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
@@ -1,18 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src"
import { Image, ImageInput } from "../../src"
import { OpenAI } from "../../src/providers"
import { dimensions } from "../lib/image"
import { recordedTests } from "../recorded-test"
const model = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
image: {
providerOptions: {
quality: "low",
outputFormat: "jpeg",
outputCompression: 10,
},
},
}).image("gpt-image-1-mini")
const recorded = recordedTests({
@@ -28,7 +22,7 @@ describe("OpenAI Images recorded", () => {
const response = yield* Image.generate({
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: { width: 1024, height: 1024 },
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
})
expect(response.images).toHaveLength(1)
@@ -37,4 +31,32 @@ describe("OpenAI Images recorded", () => {
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
recorded.effect.with(
"edits an image",
{
options: {
match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url,
},
},
() =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "Keep the simple shape and change it from black to bright green.",
images: [
ImageInput.bytes(
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
"image/jpeg",
),
],
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
})
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toBeInstanceOf(Uint8Array)
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned OpenAI image bytes")
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
}),
)
})
@@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as XAI from "../../src/providers/xai"
import * as OpenAIResponses from "../../src/protocols/openai-responses"
import * as ProviderShared from "../../src/protocols/shared"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
@@ -16,6 +17,8 @@ const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const request = LLM.request({
id: "req_1",
model,
@@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects non-image media in tool-result content with a clear error", () =>
it.effect("lowers PDF tool-result content as structured input_file array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_tool_result_pdf",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
])
}),
)
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{
type: "file",
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
mime: "application/pdf",
name: "report.pdf",
},
],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
])
}),
)
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
@@ -1526,20 +1599,64 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers user image content", () =>
it.effect("lowers user image and PDF content", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
content: [
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
{
type: "input_file",
filename: "report.pdf",
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
},
],
},
])
}),
)
it.effect("uses xAI inline file encoding for user PDFs", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: xaiModel,
messages: [
Message.user({
type: "media",
mediaType: "application/pdf",
data: "data:application/pdf;base64,JVBERi0xLjQ=",
filename: "report.pdf",
}),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{
type: "input_file",
filename: "report.pdf",
file_data: "JVBERi0xLjQ=",
mime_type: "application/pdf",
},
],
},
])
}),
@@ -1551,11 +1668,11 @@ describe("OpenAI Responses route", () => {
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
}),
)
@@ -0,0 +1,207 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { Tool } from "../../src/tool"
import { runTools } from "../lib/tool-runtime"
import { recordedTests } from "../recorded-test"
const CODE = "ORCHID-7391"
const PDF =
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
const bedrock = AmazonBedrock.configure({
apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
region: process.env.AWS_REGION ?? "us-east-1",
})
const targets: ReadonlyArray<{
readonly id: string
readonly name: string
readonly provider: string
readonly protocol: string
readonly requires: string
readonly filename: string
readonly maxTokens: number
readonly model: Model
}> = [
{
id: "openai",
name: "OpenAI Responses gpt-4o-mini",
provider: "openai",
protocol: "openai-responses",
requires: "OPENAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: openai.responses("gpt-4o-mini"),
},
{
id: "anthropic",
name: "Anthropic Haiku 4.5",
provider: "anthropic",
protocol: "anthropic-messages",
requires: "ANTHROPIC_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: anthropic.model("claude-haiku-4-5-20251001"),
},
{
id: "gemini",
name: "Gemini 3.5 Flash",
provider: "google",
protocol: "gemini",
requires: "GOOGLE_API_KEY",
filename: "verification.pdf",
maxTokens: 256,
model: google.model("gemini-3.5-flash"),
},
{
id: "xai",
name: "xAI Grok 4.5",
provider: "xai",
protocol: "openai-responses",
requires: "XAI_API_KEY",
filename: "verification.pdf",
maxTokens: 40,
model: xai.responses("grok-4.5"),
},
{
id: "bedrock",
name: "Bedrock Claude Haiku 4.5",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: "AWS_BEDROCK_API_KEY",
filename: "verification",
maxTokens: 40,
model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
},
]
const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
const prompt = "Return only the verification code from the PDF."
const readPdf = ToolDefinition.make({
name: "read_pdf",
description: "Read the attached PDF.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
})
const readPdfRuntime = Tool.make({
description: readPdf.description,
parameters: Schema.Struct({ path: Schema.String }),
success: Schema.String,
execute: () => Effect.succeed("PDF read successfully"),
toModelOutput: () => [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: "verification.pdf",
},
],
})
const expectCode = (response: LLMResponse) => {
expect(response.finishReason).toBe("stop")
expect(response.text.toUpperCase()).toContain(CODE)
}
describe("PDF recorded", () => {
for (const target of targets) {
recorded.effect.with(
`reads a user PDF with ${target.name}`,
{
id: `${target.id}-user-input`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["user-input"],
},
Effect.gen(function* () {
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_user_input`,
model: target.model,
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user([
{ type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
{ type: "text", text: prompt },
]),
],
}),
),
)
}),
)
recorded.effect.with(
`reads a PDF tool result with ${target.name}`,
{
id: `${target.id}-tool-result`,
provider: target.provider,
protocol: target.protocol,
requires: [target.requires],
tags: ["tool", "tool-result"],
},
Effect.gen(function* () {
if (target.id === "gemini") {
const events = Array.from(
yield* runTools({
request: LLM.request({
id: "recorded_pdf_gemini_tool_result",
model: target.model,
system:
"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
prompt: "Use read_pdf with path verification.pdf and return the verification code.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
}),
tools: { read_pdf: readPdfRuntime },
}).pipe(Stream.runCollect),
)
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
return
}
expectCode(
yield* LLMClient.generate(
LLM.request({
id: `recorded_pdf_${target.id}_tool_result`,
model: target.model,
system: "Read the PDF returned by the tool and follow the user's response format exactly.",
cache: "none",
generation: { maxTokens: target.maxTokens, temperature: 0 },
messages: [
Message.user(prompt),
Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
Message.tool({
id: "call_pdf_1",
name: readPdf.name,
resultType: "content",
result: [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
uri: `data:application/pdf;base64,${PDF}`,
mime: "application/pdf",
name: target.filename,
},
],
}),
],
tools: [readPdf],
}),
),
)
}),
)
}
})
@@ -0,0 +1,55 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image, ImageInput } from "../../src"
import { XAI } from "../../src/providers"
import { dimensions } from "../lib/image"
import { recordedTests } from "../recorded-test"
const model = XAI.configure({
apiKey: process.env.XAI_API_KEY ?? "fixture",
}).image("grok-imagine-image")
const recorded = recordedTests({
prefix: "xai-images",
provider: "xai",
protocol: "xai-images",
requires: ["XAI_API_KEY"],
})
describe("xAI Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat black diamond centered on a plain white background.",
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType.startsWith("image/")).toBe(true)
expect(response.image?.data).toBeInstanceOf(Uint8Array)
expect(response.image?.data.length).toBeGreaterThan(0)
}),
)
recorded.effect("edits an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "Keep the simple shape and change it from black to bright purple.",
images: [
ImageInput.bytes(
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
"image/jpeg",
),
],
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
})
expect(response.image?.mediaType).toMatch(/^image\/(jpeg|png)$/)
expect(response.image?.data).toBeInstanceOf(Uint8Array)
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned xAI image bytes")
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
}),
)
})
@@ -0,0 +1,109 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../../src"
import { XAI } from "../../src/providers"
import { Auth } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
describe("xAI Images", () => {
it.effect("generates through the OpenAI-compatible Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: XAI.configure({
apiKey: "test",
baseURL: "https://api.xai.test/v1",
http: { body: { configured: true }, headers: { "x-default": "yes" } },
}).image("grok-imagine-image"),
prompt: "A robot tending a rooftop garden",
options: {
n: 2,
aspectRatio: "16:9",
aspect_ratio: "4:3",
resolution: "1k",
responseFormat: "url",
response_format: "b64_json",
future_option: true,
},
http: {
body: { resolution: "2k", future_option: "http" },
headers: { "x-request": "yes" },
query: { trace: "1" },
},
})
expect(response.images).toHaveLength(2)
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.images[1]?.mediaType).toBe("application/octet-stream")
expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://api.xai.test/v1/images/generations?trace=1")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
model: "grok-imagine-image",
prompt: "A robot tending a rooftop garden",
n: 2,
aspect_ratio: "4:3",
resolution: "2k",
response_format: "b64_json",
future_option: "http",
configured: true,
})
return input.respond(
JSON.stringify({
data: [
{ b64_json: "AQID", url: null, mime_type: "image/jpeg" },
{ b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
],
usage: { num_images: 2 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("supports request-level custom auth", () =>
Image.generate({
model: XAI.configure({
baseURL: "https://api.xai.test/v1",
auth: Auth.custom((input) =>
Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
),
}).image("grok-imagine-image"),
prompt: "A robot tending a rooftop garden",
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.headers.get("x-custom-auth")).toBe("api.xai.test")
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
headers: { "content-type": "application/json" },
})
}),
),
),
),
),
),
)
})
@@ -0,0 +1,32 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src"
import { ZAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
const recorded = recordedTests({
prefix: "zai-images",
provider: "zai",
protocol: "zai-images",
requires: ["ZAI_API_KEY"],
})
describe("Z.ai Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat red circle centered on a plain white background.",
options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toBe("application/octet-stream")
expect(response.image?.data).toBeString()
expect(response.image?.data).toStartWith("https://")
expect(response.providerMetadata?.zai).toBeDefined()
}),
)
})
@@ -0,0 +1,130 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../../src"
import { ZAI } from "../../src/providers"
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http"
describe("Z.ai Images", () => {
it.effect("generates through the Z.ai Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: ZAI.configure({
apiKey: "test",
baseURL: "https://api.z.ai.test/api/paas/v4",
headers: { "x-default": "yes" },
http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
}).image("glm-image"),
prompt: "A red circle on a white background",
options: {
quality: "hd",
userID: "alias-user",
user_id: "raw-user",
future_option: true,
},
http: {
headers: { "x-request": "yes" },
query: { trace: "request" },
body: { quality: "final", user_id: "final-user" },
},
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toBe("application/octet-stream")
expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
expect(response.providerMetadata).toEqual({
zai: {
created: 1_760_335_349,
id: "generation-1",
requestID: "request-1",
contentFilter: [{ role: "future-role", level: 4.5 }],
},
})
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
model: "glm-image",
prompt: "A red circle on a white background",
quality: "final",
user_id: "final-user",
future_option: true,
configured: true,
})
return input.respond(
JSON.stringify({
created: 1_760_335_349,
id: "generation-1",
request_id: "request-1",
data: [{ url: "https://cdn.z.ai/generated.png" }],
content_filter: [{ role: "future-role", level: 4.5 }],
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("lets raw native options override aliases", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
prompt: "test",
options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("rejects invalid response structures", () =>
Effect.gen(function* () {
const model = ZAI.configure({ apiKey: "test" }).image("model")
const payloads = [
{},
{ data: [] },
{ data: [{ b64_json: "image" }] },
{ data: [{ url: 1 }] },
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
]
yield* Effect.forEach(payloads, (payload) =>
Image.generate({ model, prompt: "test" }).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
),
),
),
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
),
)
}),
)
})
+45
View File
@@ -183,6 +183,51 @@ describe("LLMClient tools", () => {
}),
)
it.effect("preserves provider metadata on dispatched tool results", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const providerMetadata = { google: { functionCallId: "provider_call" } }
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
result: { type: "text", value: "hello" },
output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
providerMetadata,
}),
])
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toEqual([
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: "Unknown tool: missing",
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: { type: "error", value: "Unknown tool: missing" },
providerMetadata,
}),
])
}),
)
it.effect("uses the narrow default projection for encoded typed success", () =>
Effect.gen(function* () {
const text = Tool.make({
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts"]
}
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
await expect(input).toHaveText("keep me")
})
@@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('[data-component="prompt-variant-control"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
@@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
})
}
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
}
async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.3",
"version": "1.18.4",
"description": "",
"type": "module",
"exports": {
@@ -81,7 +81,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
}
describe("SerializeAddon", () => {
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
})
describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal()
+9 -1
View File
@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
return { active, normal, alternate }
}
const getTerminalMode = (value: ITerminalCore, mode: number) => {
if (!isRecord(value)) return false
const terminal = value.wasmTerm
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
return terminal.getMode(mode) === true
}
// ============================================================================
// Types
// ============================================================================
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
return ""
}
let content = options?.range
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
content += options?.range
? this._serializeBufferByRange(normalBuffer, options.range, true)
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+11 -1
View File
@@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import {
type BaseRouterProps,
Navigate,
Route,
Router,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -29,6 +38,7 @@ import {
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
+53 -1
View File
@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & {
memory?: {
@@ -107,8 +108,45 @@ function Cell(props: {
)
}
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()}
</Tooltip>
)
}
export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage()
const platform = usePlatform()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
focus: false,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
const toggleFocus = async () => {
if (!platform.setForceFocus) return
const enabled = !state.focus
await platform.setForceFocus(enabled)
setState("focus", enabled)
}
onCleanup(() => {
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
})
let prev = ""
let start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
wide
wide={!platform.setForceFocus}
/>
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
)}
</div>
</aside>
)
@@ -0,0 +1,73 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/context/server-sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
function ConnectProviderDialogStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogConnectProvider />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open connect provider dialog
</Button>
)
}
function ProviderConnectionDialogStory(props) {
onCleanup(mockProviderAuth(props.provider, props.methods))
const dialog = useDialog()
const controller = useProviderConnectController()
controller.select(props.provider)
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open {props.provider} connection dialog
</Button>
)
}
function renderConnection(provider, methods) {
return () => (
<QueryClientProvider client={new QueryClient()}>
<ProviderConnectionDialogStory provider={provider} methods={methods} />
</QueryClientProvider>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-connect-provider",
}
export const V2 = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
</QueryClientProvider>
),
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "api", label: "API key" },
]),
}
@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import {
type Accessor,
@@ -16,6 +19,8 @@ import {
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const reset = controller.back
const back = { current: reset }
let focusHost: HTMLDivElement | undefined
const holdFocus = () => focusHost?.focus({ preventScroll: true })
const select = (provider?: string) => {
back.current = reset
controller.select(provider)
}
return (
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
function Content() {
return (
<Switch>
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm />
<CustomProviderForm autofocus={!newLayout()} />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => (
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
)}
</Match>
<Match when={true}>
<ProviderPicker directory={props.directory} onSelect={select} />
<ProviderPicker
directory={props.directory}
onSelect={select}
onPrepare={newLayout() ? holdFocus : undefined}
/>
</Match>
</Switch>
</Dialog>
)
}
return (
<Show
when={newLayout()}
fallback={
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Content />
</Dialog>
}
>
<DialogV2
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
</DialogBody>
</DialogV2>
</Show>
)
}
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
function ProviderPicker(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
)
}
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(props.directory)
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
const popular = createMemo(() =>
all()
.filter((provider) => featured.includes(provider.id))
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
)
const other = createMemo(() =>
all()
.filter((provider) => !featured.includes(provider.id))
.sort((a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
return a.name.localeCompare(b.name)
}),
)
const rows = createMemo(() => [...popular(), ...other()])
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
onMount(() => search?.focus({ preventScroll: true }))
const connect = (provider: string) => {
props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
}
const move = (event: KeyboardEvent, direction: number) => {
const items = rows()
if (items.length === 0) return
const index = items.findIndex((provider) => provider.id === store.active)
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
setStore("active", items[next].id)
picker
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
?.focus({ preventScroll: true })
event.preventDefault()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") return move(event, 1)
if (event.key === "ArrowUp") return move(event, -1)
if (event.key !== "Enter" || !store.active) return
connect(store.active)
event.preventDefault()
}
return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px">
<TextInputV2
ref={search}
type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
placeholder={language.t("dialog.provider.search.placeholder")}
value={store.filter}
onInput={(event) => {
setStore({ filter: event.currentTarget.value, active: undefined })
}}
/>
</div>
<div class="relative min-h-0 flex-1">
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<For
each={[
{ title: language.t("dialog.provider.group.popular"), items: popular },
{ title: language.t("dialog.provider.group.other"), items: other },
]}
>
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("dialog.provider.tag.recommended")}
</span>
</Show>
<Show when={provider.id === CUSTOM_ID}>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("settings.providers.tag.custom")}
</span>
</Show>
<Show when={store.connecting === provider.id}>
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
</section>
</Show>
)}
</For>
<Show when={rows().length === 0}>
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
{language.t("dialog.provider.empty")}
</div>
</Show>
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
/>
</div>
</div>
)
}
function ProviderConnection(props: {
provider: string
directory?: Accessor<string | undefined>
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory)
const alive = { value: true }
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
)
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined,
})
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
return value.label ?? ""
}
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
}
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
props.setBack(goBack)
function MethodSelection() {
if (newLayout())
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void selectMethod(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
)
}}
</For>
</div>
</div>
)
return (
<>
<div class="text-14-regular text-text-base">
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (!newLayout()) return
apiKey?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
await complete()
}
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInputV2
ref={apiKey}
class="!w-full"
name="apiKey"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return (
<div class="flex flex-col gap-6">
<Switch>
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
autofocus={!newLayout()}
ref={apiKey}
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
}
function OAuthCodeView() {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (!newLayout()) return
codeInput?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
<TextInputV2
ref={codeInput}
class="!w-full"
name="code"
placeholder={language.t("provider.connect.oauth.code.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
autofocus={!newLayout()}
ref={codeInput}
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -738,10 +1120,19 @@ function ProviderConnection(props: {
}
return (
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
<ProviderIcon
id={props.provider}
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
/>
<div
class={
newLayout()
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
: "text-16-medium text-text-strong"
}
>
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
@@ -750,8 +1141,12 @@ function ProviderConnection(props: {
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
<div
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
)
}
export function CustomProviderForm() {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
<div class="flex flex-col gap-4">
<TextField
autofocus
autofocus={props.autofocus ?? true}
label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
@@ -0,0 +1,55 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal, onMount } from "solid-js"
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
const names = [
"MiMo V2.5 Free",
"Nemotron 3 Ultra Free",
"Deepseek V4 Flash Free",
"North Mini Code Free",
"Hy3 Free",
"Big Pickle",
]
function SelectModelWithoutProviders() {
const dialog = useDialog()
const models = names.map((name, index) => ({
id: name.toLowerCase().replaceAll(" ", "-"),
name,
provider: { id: "opencode", name: "OpenCode" },
cost: { input: 0, output: 0 },
limit: { context: 128_000 },
capabilities: {
reasoning: index !== 5,
input: { text: true, image: false, audio: false, video: false, pdf: false },
},
}))
const [current, setCurrent] = createSignal(models[2])
const model = {
list: () => models,
current,
set(value) {
setCurrent(models.find((item) => item.id === value?.modelID))
},
}
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open select model dialog
</Button>
)
}
export default {
title: "App/Dialogs/Select Model",
id: "app-dialog-select-model",
}
export const WithoutProviders = {
render: () => <SelectModelWithoutProviders />,
}
@@ -1,23 +1,26 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
@@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
})
return (
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
<DialogV2
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<DialogBody class="min-h-0 flex-1 gap-0">
<ScrollView class="min-h-0 flex-1 w-full">
<div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
<For each={model.list()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
</button>
)}
</For>
<For each={freeModels()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
instant
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
<button
type="button"
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="dot-grid" size="small" />
</span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
<For
each={[...providers.popular()]
.filter((provider) => featuredProviders.includes(provider.id))
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
>
{(provider) => (
<button
type="button"
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
</Show>
</span>
</button>
)}
</For>
<button
type="button"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
{language.t("dialog.model.unpaid.viewMoreProviders")}
</button>
</div>
</div>
</div>
</ScrollView>
</div>
</DialogBody>
</DialogV2>
)
@@ -93,7 +93,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
instant
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -452,7 +452,7 @@ export function ModelSelectorPopoverV2(props: {
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
instant
value={
<ModelTooltip
model={item}
+32 -3
View File
@@ -16,6 +16,7 @@ export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
@@ -70,12 +71,40 @@ export function TabsInfoPopup() {
</button>
</div>
</Show>
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<DrawerContent
style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
@@ -0,0 +1,591 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
useCommands(props)
useEditHandler(props)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
paid={props.controller.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controller.model.selection}
providerID={props.controller.model.selection.current()?.provider?.id}
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.controller.restoreFocus}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
}
/>
}
/>
</div>
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
const files = useFile()
const layout = useLayout()
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
let editor: HTMLDivElement | undefined
const interaction = createPromptInputV2State()
const mode = () => interaction[0].mode
const history = props.history ?? createPersistedPromptInputHistory()
const tabs = () => props.controls.session.tabs
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const recent = createMemo(() => {
const all = tabs().all()
const active = activeFileTab()
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
return order.reduce<string[]>((result, tab) => {
const path = files.pathFromTab(tab)
if (!path || result.includes(path)) return result
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: mode(),
commentCount: commentCount(),
example: mode() === "shell" ? "git status" : "",
suggest: false,
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission = createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
promptLength,
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistoryNavigation: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
setPopover: (popover) => {
if (!popover) controller.dispatch({ type: "popover.close" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map((reference) => ({
id: `reference:${reference.name}`,
kind: "reference" as const,
label: `@${reference.name}`,
path: reference.path,
description: reference.description ?? referenceDescription(reference),
mention: {
type: "file" as const,
path: reference.path,
content: `@${reference.name}`,
start: 0,
end: 0,
mime: "application/x-directory",
filename: reference.name,
},
})),
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.client}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
description: resource.description,
mention: {
type: "file" as const,
path: resource.uri,
content: `@${resource.name}`,
start: 0,
end: 0,
mime: resource.mimeType ?? "text/plain",
filename: resource.name,
url: resource.uri,
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.client,
uri: resource.uri,
},
},
resource,
})),
)
const context = createMemo<PromptInputV2Suggestion[]>(() => [
...references(),
...props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent) => ({
id: `agent:${agent.name}`,
kind: "agent" as const,
label: `@${agent.name}`,
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
})),
...resources(),
...recent().map((path) => ({
id: `file:${path}`,
kind: "file" as const,
label: path,
path,
recent: true,
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
})),
])
const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({
id: `custom.${item.name}`,
trigger: item.name,
title: item.name,
description: item.description,
type: "custom" as const,
})),
...command.options
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
.map((item) => ({
id: item.id,
trigger: item.slash!,
title: item.title,
description: item.description,
type: "builtin" as const,
})),
])
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
slashCommands().map((item) => ({
id: item.id,
kind: "command",
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
const controller = createPromptInputV2Controller({
store: () => prompt.capture().store,
state: interaction,
identity: () => prompt.capture(),
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
},
commands,
context,
searchContextFiles: async (query) =>
(await files.searchFilesAndDirectories(query)).map((path) => ({
id: `file:${path}`,
kind: "file",
label: path,
path,
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
props.ref?.(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
return () => command.trigger(selected.id, "slash")
},
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
},
view: {
placeholder: designPlaceholder,
agent:
props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: props.controls.agents.select,
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined,
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
},
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
return controller as PromptInputV2ComposerController
}
function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: PromptInputV2ComposerController["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
}
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
}}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
)
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
const queueFocus = (attempts = 6) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (attempts <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
})
})
}
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
props.controls.session.tabs.setActive("review")
queueFocus()
return
}
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void props.controls.session.tabs.open(tab)
props.controls.session.tabs.setActive(tab)
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}
File diff suppressed because it is too large Load Diff
@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = {
export type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
@@ -47,7 +47,6 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
openDelay={800}
>
<div
classList={{
@@ -0,0 +1,57 @@
import type { useLocal } from "@/context/local"
import type { Prompt, usePrompt } from "@/context/prompt"
import type { PromptInputHistory } from "./history-store"
import type { FollowupDraft } from "./submit"
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export interface PromptInputProps {
class?: string
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -0,0 +1,47 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
@@ -52,12 +52,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
@@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
}
@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -31,7 +32,13 @@ let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
context: { items: [] },
})
const prompt = {
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
@@ -12,7 +12,6 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
}
@@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
@@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
const current = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
@@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
}
const selected = () => current() ?? input.controls().available[0]
const projects = () => {
const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available
@@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
}
const select = (project: PromptProject) => {
if (
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== selected()?.server?.key
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
project.server?.key !== current()?.server?.key
) {
input.controls().select(project.worktree, project.server?.key)
}
@@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
return {
selected,
empty: () => input.controls().available.length === 0,
projects,
servers,
projectKey,
@@ -446,7 +448,6 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
return (
<button
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.controller.add()}
@@ -465,7 +466,6 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
<button
{...rest}
data-action="prompt-project"
data-background-surface="project-selector"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<Show when={props.branch}>
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
<PromptGitStatus branch={props.branch} />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
return props.branch
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
)
}
@@ -4,14 +4,10 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div
data-component="session-new-design"
data-background-surface="shell"
class="relative size-full overflow-hidden bg-v2-background-bg-deep "
>
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse [&>g>g>g]:!opacity-[0.45]" />
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>

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