Compare commits

...

93 Commits

Author SHA1 Message Date
Kit Langton ffa0645572 perf(tui): batch event propagation 2026-07-21 15:50:12 -04:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] 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
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
Simon Klee 99b2e78d75 fix(tui): improve responsive footer details (#38116) 2026-07-21 19:00:21 +02:00
opencode-agent[bot] 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
Dax 69d7c2add7 feat(util): configure package publishing (#38129) 2026-07-21 12:32:23 -04:00
Shoubhit Dash cf651bc41b feat(cli): port ACP to V2 (#37907) 2026-07-21 21:13:21 +05:30
Aiden Cline 8d80365ef4 refactor(codemode): simplify model-facing wording (#38042) 2026-07-21 10:19:53 -05:00
Aiden Cline 3ea8895299 fix(core): enforce patch EOF anchors (#38039) 2026-07-21 10:15:51 -05:00
Dax 3304e79827 fix(tui): prevent autocomplete search flicker (#38128) 2026-07-21 10:41:36 -04:00
opencode-agent[bot] 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
Dax e0810753f2 refactor: extract shared util package (#37828) 2026-07-21 10:34:29 -04:00
Dax 7243bd9e12 fix: preserve runtime option defaults (#38118) 2026-07-21 10:30:18 -04:00
Simon Klee dd50d457b0 refactor(tui): simplify mini footer status line (#38112) 2026-07-21 16:08:19 +02:00
Simon Klee 592ef7433a mini: settle on wait and durable pending (#37984) 2026-07-21 15:42:55 +02:00
opencode-agent[bot] 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 Cline 7111f93836 feat(codemode): support bounded async iteration (#38040) 2026-07-21 00:02:31 -05:00
Aiden Cline e770415fd5 fix(core): normalize CRLF patch lines (#38038) 2026-07-20 23:59:10 -05:00
Aiden Cline 96dc560833 fix(core): accept padded patch markers (#38036) 2026-07-20 23:44:17 -05:00
Aiden Cline eb4ff91c2d fix(core): compose successive patch updates (#38034) 2026-07-20 23:36:48 -05:00
Aiden Cline a81c04fd31 feat(codemode): support labeled control flow (#38035) 2026-07-20 23:36:24 -05:00
Aiden Cline 63e2054f50 fix(core): improve patch errors (#38016) 2026-07-20 22:49:04 -05:00
Brendan Allan 09a38f1984 fix(server): allow authenticated CORS preflight (#38026) 2026-07-20 23:41:27 -04:00
Aiden Cline 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 Cline f5a487ffcd feat(codemode): add Set method parity (#38000) 2026-07-20 16:48:35 -05:00
Aiden Cline 455b5d3165 fix(core): match dev patch behavior (#37709) 2026-07-20 16:46:11 -05:00
James Long a2c4c7a64b refactor(tui): migrate diff viewer to V2 theme (#37999) 2026-07-20 17:29:43 -04:00
opencode-agent[bot] 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
opencode-agent[bot] 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
opencode-agent[bot] 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 Long c0ba62b972 refactor(tui): expose theme colors as properties (#37995) 2026-07-20 16:17:09 -04:00
opencode-agent[bot] 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
opencode-agent[bot] 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 Cline 0c18d06c9f feat(codemode): add groupBy parity (#37992) 2026-07-20 14:58:09 -05:00
opencode-agent[bot] 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 Long 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
opencode-agent[bot] 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 Cline 98c29075b4 feat(codemode): complete date parity (#37960) 2026-07-20 12:13:16 -05:00
Aiden Cline 6d50023457 feat(codemode): complete regexp and math parity (#37955) 2026-07-20 11:29:55 -05:00
James Long e8b19afa8f fix(tui): adapt file badge accent to theme mode (#37953) 2026-07-20 11:45:29 -04:00
Aiden Cline 3da0dea8e7 feat(codemode): expand standard library parity (#37943) 2026-07-20 09:59:37 -05:00
Dax Raad c7aa47c144 feat(tui): implement session timeline 2026-07-20 09:53:07 -04:00
James Long 7be95bcd6a feat(tui): support single-mode themes (#37930) 2026-07-20 09:37:48 -04:00
opencode-agent[bot] 3f4fb3f9db fix(tui): show opencode2 continuation command (#37933)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-20 13:36:02 +00:00
James Long 6702ce0d3f fix(tui): soften theme scale extremes (#37555) 2026-07-20 09:20:20 -04:00
Simon Klee a9895a1e8a fix(tui/mini): idle footer when session idles (#37910) 2026-07-20 13:22:46 +02:00
Simon Klee 75c7ac6a2c tui/mini: consolidate stream and panel internals (#37903) 2026-07-20 12:45:48 +02:00
Simon Klee 0eb71d0fc7 refactor(tui): extract shared frontend helpers (#37868) 2026-07-20 10:43:02 +02:00
Aiden Cline deee40c572 fix(codemode): parse canonical index keys (#37851) 2026-07-19 23:55:53 -05:00
Aiden Cline 8b5655ed53 fix(ai): preserve compatible reasoning details (#37708) 2026-07-19 22:30:55 -05:00
opencode-agent[bot] b9525b5878 fix(ai): expand context overflow patterns (#37848)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:22:56 -05:00
opencode-agent[bot] 2b8d1998d6 fix(ai): buffer partial tool call identity (#37847)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 22:12:49 -05:00
Aiden Cline 391cfbcfe7 fix(codemode): align string, array, and Date behavior (#37775) 2026-07-19 21:41:06 -05:00
Dax Raad fe0c74f4df fix(cli): accept updated service on restart 2026-07-19 19:29:40 -04:00
Kit Langton 6d32bc9cb0 feat(simulation): control arbitrary tool lifecycles (#37816) 2026-07-19 17:40:30 -04:00
Simon Klee 925c2423de mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
2026-07-19 22:45:10 +02:00
Kit Langton 71cb419570 fix(simulation): validate semantic click identity (#37808) 2026-07-19 16:43:53 -04:00
Kit Langton edc93ceff1 feat(simulation): expose semantic UI snapshots (#37802) 2026-07-19 16:04:27 -04:00
opencode-agent[bot] 86a468c4d8 fix(tui): move agent cycling to shift-tab (#37706)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-19 21:45:03 +02:00
opencode-agent[bot] f05d2ab551 fix(tui): load root sessions in switcher (#37784)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-19 16:32:32 +00:00
opencode-agent[bot] bef6cfbffe feat(client): support opaque payload schemas (#37773)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 16:08:26 +00:00
Aiden Cline fe9a936867 feat(server): expose runtime MCP controls over HTTP (#37712) 2026-07-19 10:28:25 -05:00
Aiden Cline d5669ca934 docs(codemode): streamline README (#37769) 2026-07-19 10:18:24 -05:00
opencode-agent[bot] 04f0a771a3 feat(ai): add OpenAI image generation (#37714)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-19 10:17:49 -05:00
Simon Klee c50554d907 mini: move frontend into tui package (#37754) 2026-07-19 14:12:22 +02:00
Simon Klee 3f5ad8441f cli: extract run from mini package (#37737) 2026-07-19 11:11:03 +02:00
Simon Klee cf6e5b3604 mini: fix shell tool output display (#37711) 2026-07-19 09:31:38 +02:00
Aiden Cline cbcf191fdb fix(core): detach disposed MCP registrations from root scope (#37660) 2026-07-18 23:38:07 -05:00
Kit Langton ba0bbdafaa fix(core): preserve the first terminal failure (#37705) 2026-07-19 00:00:23 -04:00
Kit Langton a288cb5a0c fix(core): continue after malformed tool input (#37701) 2026-07-18 23:09:08 -04:00
Kit Langton 57ff57595a fix(core): safely recover malformed tool input (#37698) 2026-07-18 21:52:45 -04:00
Kit Langton 584fdefe6f fix(simulation): render screenshot symbol glyphs (#37691) 2026-07-18 21:36:21 -04:00
Kit Langton c310ef82f4 fix(core): authorize relative external paths (#37689) 2026-07-18 19:55:18 -04:00
Dax Raad 4e85a37590 fix(tui): auto-approve permissions in auto mode 2026-07-18 17:54:59 -04:00
opencode-agent[bot] fe9b051d1a feat(core): allow MCP Code Mode opt-out (#37681)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-18 17:17:56 -04:00
1553 changed files with 44531 additions and 345508 deletions
+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.
+899 -1397
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",
+171 -3
View File
@@ -1,6 +1,6 @@
# @opencode-ai/ai
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
@@ -24,6 +24,172 @@ const program = Effect.gen(function* () {
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Image generation
Use `Image.generate` with an image model for direct asset generation:
```ts
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",
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
const program = Effect.gen(function* () {
const response = yield* LLM.generate(
LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
prompt: "Design a solarpunk rooftop garden, then show me.",
tools: [OpenAI.imageGeneration({ quality: "high" })],
}),
)
return response.message
})
```
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
@@ -32,6 +198,8 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Caching
@@ -104,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
@@ -182,7 +350,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
+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": [
+38
View File
@@ -0,0 +1,38 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
import type { LLMError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
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 = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, LLMError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
})
}),
)
export const ImageClient = {
Service,
layer,
generate,
} as const
+166
View File
@@ -0,0 +1,166 @@
import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
readonly id: string
readonly generate: (
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
}
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<Options>
readonly http?: HttpOptions
constructor(input: ImageModel.Input<Options>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.http = input.http
}
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,
http: input.http,
})
}
}
export namespace ImageModel {
export interface Input<Options extends ImageOptions = ImageOptions> {
readonly id: ModelID
readonly provider: ProviderID
readonly route: ImageRoute<Options>
readonly http?: HttpOptions
}
export interface MakeInput<Options extends ImageOptions = ImageOptions>
extends Omit<Input<Options>, "id" | "provider"> {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
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,
images: Schema.optional(Schema.Array(ImageInputSchema)),
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
http: Schema.optional(HttpOptions),
}) {
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]),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
images: Schema.Array(GeneratedImage),
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
get image() {
return this.images[0]
}
}
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 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((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
}
export const Image = {
request,
generate,
} as const
+4
View File
@@ -1,4 +1,5 @@
export { LLMClient } from "./route/client"
export { ImageClient } from "./image-client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
@@ -10,6 +11,9 @@ export type {
Service as LLMClientService,
} from "./route/client"
export * from "./schema"
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"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
@@ -703,7 +703,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
providerExecuted: block.type === "server_tool_use",
}),
},
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
[
...events,
LLMEvent.toolInputStart({
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use" ? true : undefined,
}),
],
]
}
@@ -561,7 +561,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
hasToolCalls:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasToolCalls,
lifecycle,
tools: result.tools,
reasoningSignatures: Object.fromEntries(
+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
+1
View File
@@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages"
export * as BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat"
export * as OpenAIImages from "./openai-images"
export * as OpenAICompatibleChat from "./openai-compatible-chat"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
export * as OpenAIResponses from "./openai-responses"
+148 -19
View File
@@ -77,6 +77,7 @@ const OpenAIChatMessage = Schema.Union([
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(Schema.Unknown),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -149,6 +150,7 @@ const OpenAIChatDelta = Schema.Struct({
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)),
})
@@ -164,13 +166,23 @@ export const OpenAIChatEvent = Schema.Struct({
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
}
export interface ParserState {
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
readonly reasoningDetails: Array<unknown>
readonly reasoningDetailsObserved: boolean
readonly reasoningEmitted: boolean
}
// =============================================================================
@@ -216,7 +228,15 @@ 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 "reasoning_content"
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.openai?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
@@ -260,19 +280,28 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
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 (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningContent = (() => {
if (reasoning.length === 0) return nativeReasoning
if (field === "reasoning_content") return text
})()
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content:
reasoning.length === 0
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
: field === "reasoning_content"
? text
: undefined,
reasoning_content: reasoningContent,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details,
}
})
@@ -423,6 +452,59 @@ const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
}
const detailText = (details: ReadonlyArray<unknown>) => {
const text = details.flatMap((detail) => {
if (!isRecord(detail)) return []
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
return [detail.summary]
return []
})
if (text.length > 0) return text.join("")
}
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
for (const detail of details) {
const previous = result.at(-1)
if (
!isRecord(previous) ||
previous.type !== "reasoning.text" ||
!isRecord(detail) ||
detail.type !== "reasoning.text" ||
conflictingReasoningTextDetails(previous, detail)
) {
result.push(detail)
continue
}
result[result.length - 1] = {
...previous,
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
signature: mergeDetailValue(previous.signature, detail.signature),
format: mergeDetailValue(previous.format, detail.format),
}
}
}
const mergeDetailValue = (previous: unknown, current: unknown) =>
previous || current || (previous !== undefined ? previous : current)
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
conflictingDetailValue(previous.id, current.id) ||
conflictingDetailValue(previous.index, current.index) ||
conflictingDetailValue(previous.format, current.format) ||
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
})
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -432,29 +514,56 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let pendingTools = state.pendingTools
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? reasoning?.field
if (reasoning)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
openai: { reasoningField: reasoningField ?? reasoning.field },
})
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
if (!current && (!id || !name)) {
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[tool.index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
tool.index,
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result)) return yield* result
@@ -463,8 +572,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// JSON parse failures fail the stream at the boundary rather than at halt.
// valid calls and malformed local calls settle independently.
const finished =
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
? yield* ToolStream.finishAll(ADAPTER, tools)
@@ -473,11 +585,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
lifecycle,
reasoningField,
reasoningDetails: state.reasoningDetails,
reasoningDetailsObserved,
reasoningEmitted,
},
events,
] as const
@@ -487,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
@@ -512,9 +637,13 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
reasoningDetails: [],
reasoningDetailsObserved: false,
reasoningEmitted: false,
}),
step,
onHalt: finishEvents,
+270
View File
@@ -0,0 +1,270 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
ImageModel,
GeneratedImage,
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,
} 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 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>
export type OpenAIImageBody = Record<string, unknown> & {
readonly model: string
readonly prompt: string
}
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
b64_json: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
revised_prompt: Schema.optional(Schema.String),
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, 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: OpenAIImageOptions | undefined) => {
if (!options) return undefined
const { mask: _, outputFormat, outputCompression, ...native } = options
return {
output_format: outputFormat,
output_compression: outputCompression,
...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<OpenAIImageOptions> = {
id: ADAPTER,
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)
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",
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"),
),
)
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,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
),
)
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 = {
model,
} as const
+82 -22
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Encoding, Schema } from "effect"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -25,6 +25,7 @@ import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
import { OpenAIImage } from "./utils/openai-image"
const ADAPTER = "openai-responses"
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
@@ -113,11 +114,24 @@ const OpenAIResponsesTool = Schema.Struct({
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
const OpenAIResponsesToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
Schema.Struct({ type: Schema.tag("image_generation") }),
])
// Fields shared between the HTTP body and the WebSocket `response.create`
@@ -128,7 +142,7 @@ const OpenAIResponsesCoreFields = {
model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem),
instructions: Schema.optional(Schema.String),
tools: optionalArray(OpenAIResponsesTool),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
@@ -194,6 +208,8 @@ const OpenAIResponsesStreamItem = Schema.Struct({
outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
error: Schema.optional(Schema.Unknown),
encrypted_content: optionalNull(Schema.String),
})
@@ -258,21 +274,41 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
}
const nativeImageTool = (tool: ToolDefinition) => {
const native = nativeImageToolInput(tool)
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* invalid("OpenAI Responses image generation tool options are invalid")
}
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false,
}
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "required" as const,
tool: (name) => ({ type: "function" as const, name }),
tool: (name) =>
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
? ({ type: "image_generation" } as const)
: { type: "function" as const, name },
})
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
@@ -420,6 +456,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
const itemID = hostedToolItemID(part)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.name === "image_generation" && part.result.type === "content") {
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, lowerToolResultContentItem),
})
}
if (itemID) hostedToolReferences.add(itemID)
continue
}
@@ -485,10 +528,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -574,14 +617,29 @@ const isReasoningItem = (
// Round-trip the full item as the structured result so consumers can extract
// outputs / sources / status without re-decoding.
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
const isError = typeof item.error !== "undefined" && item.error !== null
if (item.type === "image_generation_call" && item.result) {
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
return {
type: "content" as const,
value: [
{
type: "file" as const,
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
mime: `image/${item.output_format ?? "png"}`,
},
],
}
}
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
}
})
const hostedToolEvents = (
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
): ReadonlyArray<LLMEvent> => {
) {
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
@@ -595,12 +653,12 @@ const hostedToolEvents = (
LLMEvent.toolResult({
id: item.id,
name: tool.name,
result: hostedToolResult(item),
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
]
}
})
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -835,7 +893,9 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
{
...state,
lifecycle,
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
},
events,
@@ -845,7 +905,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isHostedToolItem(item)) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...hostedToolEvents(item))
events.push(...(yield* hostedToolEvents(item)))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
@@ -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
+1 -1
View File
@@ -44,7 +44,7 @@ export const reasoningDelta = (
providerMetadata?: ProviderMetadata,
): State => {
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text }))
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
return started
}
@@ -0,0 +1,20 @@
import { Schema } from "effect"
const dimensions = (value: string) => {
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return undefined
return { width: Number(match[1]), height: Number(match[2]) }
}
export const Size = Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const parsed = dimensions(value)
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
}),
)
export const OpenAIImage = {
Size,
} as const
+40 -32
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
})
@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
text,
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -122,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
missingToolMessage: string,
): AppendOutcome<K> | LLMError => {
const current = tools[key]
const id = delta.id ?? current?.id
const name = delta.name ?? current?.name
const id = current?.id ?? delta.id
const name = current?.name ?? delta.name
if (!id || !name) return eventError(route, missingToolMessage)
const tool = {
@@ -158,8 +176,9 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and return the optional public `tool-call` event. Missing keys are
* a no-op because some providers emit stop events for non-tool content blocks.
* from state, and return either a call or a non-executable local input error.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
Effect.gen(function* () {
@@ -167,10 +186,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool),
],
events: finishEvents(tool, yield* toolCall(route, tool)),
}
})
@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool, input),
],
events: finishEvents(tool, yield* toolCall(route, tool, input)),
}
})
/**
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
* not emit per-tool stop events, so all accumulated calls finish when the choice
* receives a terminal `finish_reason`.
* not emit per-tool stop events, so all accumulated calls finish independently
* when the choice receives a terminal `finish_reason`.
*/
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
Effect.gen(function* () {
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
return {
tools: empty<K>(),
events: yield* Effect.forEach(pending, (tool) =>
toolCall(route, tool).pipe(
Effect.map((call) => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
call,
]),
),
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
).pipe(Effect.map((events) => events.flat())),
}
})
+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
+11 -1
View File
@@ -16,13 +16,17 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
/input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
@@ -34,11 +38,17 @@ const patterns = [
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
+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"
+49 -1
View File
@@ -1,12 +1,14 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { Route, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export type { OpenAIImageOptions } from "../protocols/openai-images"
export const id = ProviderID.make("openai")
@@ -22,6 +24,39 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface ImageGenerationOptions {
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly inputFidelity?: OpenAIImageString<"low" | "high">
readonly outputCompression?: number
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
readonly partialImages?: number
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 = {}) =>
ToolDefinition.make({
name: "image_generation",
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
openai: {
type: "image_generation",
action: options.action,
background: options.background,
input_fidelity: options.inputFidelity,
output_compression: options.outputCompression,
output_format: options.outputFormat,
partial_images: options.partialImages,
quality: options.quality,
size: options.size,
},
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
@@ -55,6 +90,17 @@ export const configure = (input: Config = {}) => {
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
})
return {
id,
@@ -62,6 +108,7 @@ export const configure = (input: Config = {}) => {
responses,
responsesWebSocket,
chat,
image,
configure,
}
}
@@ -97,3 +144,4 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const image = provider.image
+25 -7
View File
@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.openrouter),
}) as OpenRouterBody,
),
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
const messages = body.messages.map((message) => {
if (message.role !== "assistant") return message
const source = sourceAssistants[assistantIndex++]
const reasoning = source?.content
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("")
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
return {
...message,
reasoning_content: undefined,
reasoning_text: undefined,
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
reasoning_details: reasoningDetails,
}
})
return {
...body,
messages,
...bodyOptions(request.providerOptions?.openrouter),
} as OpenRouterBody
}),
),
},
stream: OpenAIChat.protocol.stream,
+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
+2 -2
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput {
readonly request: LLMRequest
readonly request: { readonly http?: HttpOptions }
readonly method: "POST" | "GET"
readonly url: string
readonly body: string
+18
View File
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
@@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
/** A local tool call whose final input could not be decoded. */
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
name: Schema.String,
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
@@ -216,6 +226,7 @@ const llmEventTagged = Schema.Union([
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
ToolInputError,
ToolCall,
ToolResult,
ToolError,
@@ -253,6 +264,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
ToolResult.make({
@@ -283,6 +296,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolInputError: llmEventTagged.guards["tool-input-error"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
@@ -548,6 +562,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
return reduceToolInputDelta(next, event)
case "tool-input-end":
return reduceToolInputEnd(next, event)
case "tool-input-error": {
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
return { ...next, toolInputs }
}
case "tool-call":
return reduceToolCall(next, event)
case "tool-result":
+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
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\"}"
}
}
]
}
+578
View File
@@ -0,0 +1,578 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient, ImageInput } from "../src"
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
import { it } from "./lib/effect"
import { dynamicResponse } from "./lib/http"
describe("Image", () => {
it.effect("generates images through the OpenAI Images API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: OpenAI.configure({
apiKey: "test",
baseURL: "https://api.openai.test/v1",
queryParams: { "api-version": "v1" },
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
}).image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
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: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
headers: { "x-request": "yes" },
query: { trace: "1" },
},
})
expect(response.images).toHaveLength(2)
expect(response.image?.mediaType).toBe("image/webp")
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage?.totalTokens).toBe(12)
}).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/generations?api-version=v1&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: "gpt-image-2",
prompt: "A robot tending a rooftop garden",
n: 2,
size: "2048x2048",
quality: "future-quality",
background: "opaque",
output_format: "webp",
output_compression: 50,
native_default: true,
future_option: "http",
deployment: "test",
request_metadata: "value",
})
return input.respond(
JSON.stringify({
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
output_format: "webp",
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("preserves native snake_case and unknown request options", () =>
Image.generate({
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.tap((response) =>
Effect.sync(() => {
expect(response.image?.mediaType).toBe("image/avif")
}),
),
Effect.provide(
ImageClient.layer.pipe(
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
+24 -2
View File
@@ -3,8 +3,30 @@ import { isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Too many tokens",
"Token limit exceeded",
]
expect(messages.every(isContextOverflow)).toBe(true)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
"Rate limit exceeded, please retry after 30 seconds.",
"Too many requests. Please slow down.",
]
expect(messages.some(isContextOverflow)).toBe(false)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
@@ -484,6 +484,30 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
},
{ type: "content_block_stop", index: 0 },
)
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error).toBeInstanceOf(LLMError)
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
}),
)
it.effect("fails with a typed provider error for stream error frames", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -303,6 +303,32 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed tool input as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
id: "tool_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
}),
)
it.effect("decodes reasoning deltas", () =>
Effect.gen(function* () {
const body = eventStreamBody(
+54 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { LLM, LLMEvent } from "../../src"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
@@ -83,6 +83,59 @@ describe("Cloudflare", () => {
}),
)
it.effect("preserves reasoning details for AI Gateway continuation", () =>
Effect.gen(function* () {
const model = CloudflareAIGateway.configure({
accountId: "test-account",
gatewayId: "test-gateway",
apiKey: "test-token",
}).model("anthropic/claude-sonnet-4.6")
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "Thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(
sseEvents(
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
deltaChunk({ reasoning_details: [details[2]] }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
])
}),
)
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
Effect.gen(function* () {
expect(
@@ -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,10 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import { LLM, LLMEvent, LLMResponse } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
const cases = [
{
@@ -15,6 +17,7 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
structured: true,
},
{
name: "Vercel AI Gateway",
@@ -26,6 +29,7 @@ const cases = [
}).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
structured: true,
},
] as const
@@ -57,11 +61,82 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning" },
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model: item.model, messages: [response.message] }),
)
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
expect(Array.isArray(replayDetails)).toBe(true)
if (!Array.isArray(replayDetails)) return
expect(replayDetails).toEqual(details)
expect(replayDetails).toHaveLength(1)
expect(replayDetails[0]).toMatchObject({
type: "reasoning.text",
text: response.reasoning,
signature: expect.any(String),
})
}),
30_000,
)
recorded.effect.with(
"continues signed reasoning through a tool loop",
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: `${item.cassette}-tool-loop`,
model: item.model,
maxTokens: 1536,
temperature: false,
}),
)
expectWeatherToolLoop(events)
expect(
LLMResponse.text({
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
}).trim(),
).toMatch(/^Paris is sunny\.?$/)
const details = events
.filter(LLMEvent.is.reasoningEnd)
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
.find(Array.isArray)
expect(Array.isArray(details)).toBe(item.structured)
if (!item.structured || !Array.isArray(details)) return
expect(
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
}),
60_000,
)
})
}
@@ -570,6 +570,375 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
]
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
],
},
finish_reason: "tool_calls",
},
],
},
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "thinking",
reasoning_details: details,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
},
])
}),
)
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ 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: { reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
}),
)
it.effect("preserves unknown reasoning details while using scalar display text", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ 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: "reasoning", reasoningDetails: details },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("uses scalar display text for signature-only reasoning details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
{ 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: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning: "scalar" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: details },
})
}),
)
it.effect("preserves an explicitly empty reasoning details array", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningDetails: [] },
})
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
}),
)
it.effect("attaches signature-only details that arrive after content", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
]
const merged = [
{
type: "reasoning.text",
text: "thinking",
signature: "signed",
format: "anthropic-claude-v1",
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
])
}),
)
it.effect("preserves metadata-only reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
])
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}),
)
it.effect("flushes details-only display reasoning when the stream ends", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
expect(response.reasoning).toBe("summary")
expect(response.message.content).toEqual([
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
])
}),
)
it.effect("replays details from multiple reasoning parts in order", () =>
Effect.gen(function* () {
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "first",
providerMetadata: { openai: { reasoningDetails: [first] } },
},
{
type: "reasoning",
text: "second",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
},
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
])
}),
)
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
Effect.gen(function* () {
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "A",
providerMetadata: { openai: { reasoningDetails: [detail] } },
},
{ type: "reasoning", text: "B" },
]),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
])
}),
)
it.effect("replays native scalar reasoning alongside native details", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: [{ type: "reasoning", text: "thinking" }],
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
}),
],
}),
)
expect(replay.body.messages).toEqual([
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
])
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -606,6 +975,67 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("buffers tool call deltas until the function name arrives", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
}),
deltaChunk({
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
}),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
}),
)
it.effect("fails when a buffered tool call never receives a function name", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
}),
deltaChunk({}, "tool_calls"),
)
const error = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
}),
)
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -0,0 +1,62 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
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("gpt-image-1-mini")
const recorded = recordedTests({
prefix: "openai-images",
provider: "openai",
protocol: "openai-images",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat black circle centered on a plain white background.",
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
})
expect(response.images).toHaveLength(1)
expect(response.image?.mediaType).toBe("image/jpeg")
expect(response.image?.data).toBeInstanceOf(Uint8Array)
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 })
}),
)
})
@@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src"
import { OpenAI } from "../../src/providers"
import { recordedTests } from "../recorded-test"
const openai = OpenAI.configure({
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
})
const recorded = recordedTests({
prefix: "openai-responses-images",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
})
describe("OpenAI Responses image generation recorded", () => {
recorded.effect("generates and edits an image with the hosted tool", () =>
Effect.gen(function* () {
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
const tools = [
OpenAI.imageGeneration({
action: "auto",
quality: "low",
size: "1024x1024",
outputFormat: "jpeg",
outputCompression: 10,
partialImages: 0,
}),
]
const response = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial],
tools,
toolChoice: "image_generation",
}),
)
const result = response.events.find(LLMEvent.is.toolResult)
expect(result).toBeDefined()
expect(result?.providerExecuted).toBe(true)
expect(result?.result.type).toBe("content")
if (result?.result.type !== "content") return
expect(result.result.value).toHaveLength(1)
expect(result.result.value[0]?.type).toBe("file")
if (result.result.value[0]?.type !== "file") return
expect(result.result.value[0].mime).toBe("image/jpeg")
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
const edited = yield* LLM.generate(
LLM.request({
model: openai.responses("gpt-5-mini"),
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
tools,
toolChoice: "image_generation",
}),
)
const editedResult = edited.events.find(LLMEvent.is.toolResult)
expect(editedResult?.result.type).toBe("content")
if (editedResult?.result.type !== "content") return
expect(editedResult.result.value[0]?.type).toBe("file")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -58,6 +58,39 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
toolChoice: "image_generation",
}),
)
expect(prepared.body.tools).toEqual([
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
])
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
}),
)
it.effect("rejects invalid hosted image generation options locally", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "Show me a rooftop garden.",
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("image generation tool options are invalid")
}),
)
it.effect("lowers semantic service tier options", () =>
Effect.gen(function* () {
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
@@ -1103,6 +1136,48 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted image generation with the generated image", () =>
Effect.gen(function* () {
const imageTool = OpenAI.imageGeneration({ action: "edit" })
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.user("Generate a black triangle."),
Message.assistant([
ToolCallPart.make({
id: "ig_1",
name: "image_generation",
input: {},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
ToolResultPart.make({
id: "ig_1",
name: "image_generation",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
]),
Message.user("Make it blue."),
],
tools: [imageTool],
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -1259,6 +1334,69 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
}),
)
it.effect("settles malformed function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
},
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
})
expect(response.finishReason).toBe("tool-calls")
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
@@ -1298,6 +1436,59 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
},
{ type: "response.completed", response: {} },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("invalid image base64")
}),
)
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
Effect.gen(function* () {
const item = {
+99 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM } from "../../src"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import * as OpenRouter from "../../src/providers/openrouter"
import { it } from "../lib/effect"
@@ -53,4 +53,102 @@ describe("OpenRouter", () => {
})
}),
)
it.effect("preserves manually supplied reasoning details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
},
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
reasoning: "Thinking",
reasoning_details: details,
},
])
}),
)
it.effect("preserves opaque and duplicate continuation details", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
])
}),
)
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
]
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [
Message.assistant({
type: "reasoning",
text: "AB",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
])
}),
)
it.effect("omits scalar reasoning without continuation details", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
}),
)
})
@@ -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"))),
),
)
}),
)
})
+2 -23
View File
@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
throw new Error("Weather tool loop exceeded 10 steps")
})
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
const content: ContentPart[] = []
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
const type = event.type === "text-delta" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
} else {
content.push({ type, text: event.text })
}
continue
}
if (event.type === "text-end" || event.type === "reasoning-end") {
const type = event.type === "text-end" ? "text" : "reasoning"
const last = content.at(-1)
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
continue
}
if (event.type === "tool-call") content.push(event)
}
return content
}
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
export const expectFinish = (
events: ReadonlyArray<LLMEvent>,
+8 -2
View File
@@ -3,6 +3,8 @@ import { Layer } from "effect"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import { ImageClient } from "../src/image-client"
import type { Service as ImageClientService } from "../src/image-client"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@@ -15,7 +17,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -81,6 +83,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
),
)
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
return Layer.mergeAll(
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(deps)),
)
},
})
+15
View File
@@ -95,4 +95,19 @@ describe("LLMResponse reducer", () => {
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
})
test("clears malformed tool input without appending an executable call", () => {
const state = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
LLMEvent.toolInputError({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
}),
])
expect(state.toolInputs).toEqual({})
expect(state.message.content).toEqual([])
})
})
+94
View File
@@ -36,6 +36,33 @@ describe("ToolStream", () => {
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query"' },
"missing tool",
)
if (ToolStream.isError(first)) return yield* first
const second = ToolStream.appendOrStart(
ADAPTER,
first.tools,
0,
{ id: "", name: "", text: ':"weather"}' },
"missing tool",
)
if (ToolStream.isError(second)) return yield* second
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
}),
)
it.effect("fails appendExisting when the provider skipped the tool start", () =>
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
@@ -64,6 +91,73 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes malformed local input as a non-executable tool error", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished).toEqual({
tools: {},
events: [
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
)
it.effect("preserves valid siblings when one parallel input is malformed", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
name: "lookup",
input: '{"query":"weather"}',
})
const tools = ToolStream.start(valid, 1, {
id: "call_invalid",
name: "lookup",
input: '{"query":"partial',
})
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{
type: "tool-input-error",
id: "call_invalid",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
)
it.effect("keeps malformed provider-executed input terminal", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "web_search",
input: '{"query":"partial',
providerExecuted: true,
})
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
expect(result._tag).toBe("Failure")
}),
)
it.effect("preserves providerExecuted and clears all tools", () =>
Effect.gen(function* () {
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
+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)
@@ -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}
openDelay={0}
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>
)
+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)
@@ -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)
}
@@ -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,
@@ -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>
)
}
@@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@@ -19,6 +20,7 @@ export function SortableTabV2(props: {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const sortable = useSortable({
get id() {
return props.tab
@@ -39,9 +41,15 @@ export function SortableTabV2(props: {
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
<TooltipV2
value={
<>
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
gutter={10}
>
@@ -52,7 +60,7 @@ export function SortableTabV2(props: {
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
</TooltipV2>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
@@ -133,9 +133,10 @@
}
[data-slot="settings-v2-row-description"] {
margin-block: -3.5px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 20px;
color: var(--v2-text-text-muted);
}
@@ -270,10 +271,10 @@
}
.settings-v2-provider-description {
margin: 0;
margin-block: -3.5px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 20px;
color: var(--v2-text-text-muted);
}
+10 -1
View File
@@ -292,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
if (lastSize?.cols === cols && lastSize?.rows === rows) {
pendingSize = undefined
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
sizeTimer = undefined
return
}
pendingSize = { cols, rows }
@@ -317,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
createEffect(() => {
const colors = terminalColors()
const mode = theme.mode() === "dark" ? "dark" : "light"
if (!term) return
setOptionIfSupported(term, "theme", colors)
setOptionIfSupported(term, "colorScheme", mode)
})
createEffect(() => {
@@ -396,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
}
_ghostty = g
term = t
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
output = terminalWriter((data, done) =>
t.write(data, () => {
done?.()
@@ -595,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
tries = 0
local.onConnect?.()
scheduleSize(t.cols, t.rows)
if (t.getMode(2031)) t.write("\x1b[?996n")
}
const handleMessage = (event: MessageEvent) => {
+21 -6
View File
@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
"md:pl-4": !mac(),
}}
>
<ChannelIndicator />
<ChannelIndicator debugTools={props.debugTools} />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show>
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
@@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator() {
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
{["beta", "dev"].includes(channel) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
{channel.toUpperCase()}
</div>
)}
</>
+8 -1
View File
@@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
onCleanup(() => states.forEach((value) => value.dispose()))
const selected = () => ensure(activeServer())
const selected = () => {
const list = global.servers.list()
const key = activeServer()
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (!conn) throw new Error("Notification server not found")
return ensure(ServerConnection.key(conn))
}
return {
ready: () => selected().ready(),
+3
View File
@@ -115,6 +115,9 @@ type PlatformBase = {
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
setForceFocus?(enabled: boolean): Promise<void>
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}

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