Compare commits

..
Author SHA1 Message Date
usrnk1 8e648913e3 docs(desktop): capture slash-command queue comparison 2026-09-09 15:50:48 +02:00
usrnk1 e28fcf5edb fix(desktop): respect follow-up behavior for slash commands 2026-09-09 15:50:48 +02:00
Kit Langton 4ea368e09e fix(core): exclude hidden glob matches before limiting results 2026-09-09 09:41:07 -04:00
Kit Langton c6977a836f fix(core): preserve trailing blank lines in read pages 2026-09-09 09:31:19 -04:00
Kit Langton 1dcc6551d9 fix(core): preserve literal command arguments 2026-09-09 09:17:25 -04:00
Kit Langton 9c8fb89979 fix(core): preserve inline code boundary backticks (#47410) 2026-09-09 09:15:52 -04:00
Kit Langton 51c926c3ac fix(core): terminate exhausted Markdown code budgets (#47408) 2026-09-09 09:15:11 -04:00
Shoubhit Dash 65152b7936 feat(core): carry compaction usage on the compaction message (#47974) 2026-09-09 17:07:50 +05:30
Shoubhit Dash 3c4c7b41be fix(core): close websocket after provider error frame (#47973) 2026-09-09 16:50:20 +05:30
opencode-agent[bot]andHona d461154a8d fix(session-ui): lead tool summaries with Used (#48102)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-09 18:39:37 +10:00
Victor Navarro cebd25022f fix(core): honor device token organization (#46570) 2026-09-09 10:11:28 +02:00
David HillandLukeParkerDev 9128e847bd fix(app): refine browser toolbar and empty state (#48061)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-09 07:17:11 +00:00
David Hill 8b92833624 fix(app): constrain worktree dropdown spacing (#48062) 2026-09-09 16:57:59 +10:00
David Hill f4dd76913f fix(session-ui): simplify file search styling (#48063) 2026-09-09 16:57:41 +10:00
David Hill ef88566d61 fix(app): smooth right panel transitions (#48086) 2026-09-09 16:57:31 +10:00
David Hill b3f765c17d fix(app): reorder and rename browser experiment (#48087) 2026-09-09 16:56:29 +10:00
David Hill ab2366de2e fix(app): improve queued message previews (#48091) 2026-09-09 16:56:16 +10:00
Aiden Cline c0aa963c13 refactor(codemode): split the interpreter into a per-execution Runtime and per-call Frame (#48082) 2026-09-09 00:43:48 -05:00
Luke Parker d55d941f3c fix(app): restore selected review pane tab per session (#48079) 2026-09-09 05:42:40 +00:00
Luke Parker 2dea1f3d0e fix(browser): suspend and restore idle desktop tabs (#48080) 2026-09-09 15:15:11 +10:00
Aiden Cline fcafe82cdc fix(core): hide Bedrock bare IDs that require inference profiles (#48078) 2026-09-09 00:06:05 -05:00
Aiden Cline 1fe06bb4ed refactor(codemode): define built-in globals as host functions and namespaces (#48072) 2026-09-08 23:57:03 -05:00
Aiden Cline c1f4beaf40 feat(ai): add Alibaba inference provider (#47874) 2026-09-08 23:29:02 -05:00
Aiden Cline e628143448 fix(core): drop retained-tail reference from compaction rules (#48074) 2026-09-08 23:23:09 -05:00
Aiden Cline 148042ab81 fix(core): trim compaction summary prompt (#48058) 2026-09-08 22:56:54 -05:00
Brendan Allan b7aea8b0ef fix(session-ui): fade in streamed inline code (#44899) 2026-09-09 03:42:46 +00:00
opencode-agent[bot]andrekram1-node 1623ac3ba9 test(cli): update auth account coverage (#48066)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-08 22:28:48 -05:00
opencode-agent[bot]andBrendonovich f02c5f8648 fix(app): follow rendered model order with arrow keys (#47912)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 11:23:38 +08:00
Aiden Cline 0da0772bc0 refactor(codemode): use acorn's typed AST in the interpreter (#48060) 2026-09-08 22:20:12 -05:00
Aiden Cline 08e28fb915 refactor(codemode): name the data boundary and prepare tools once (#48021) 2026-09-08 21:35:36 -05:00
Dax 74b0fa9d1f feat(cli): add V2 uninstall command (#48056) 2026-09-08 22:34:35 -04:00
Dax dbd9b18f3d refactor(cli): move import and export under session (#48055) 2026-09-08 21:59:58 -04:00
opencode-agent[bot]andBrendonovich b43e1c682b test(app): remove subagent link coverage (#48053)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 01:54:59 +00:00
opencode-agent[bot]andBrendonovich d11f5916ee test(app): remove subagent link coverage (#48052)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 01:52:09 +00:00
opencode-agent[bot]andBrendonovich f1ce69d2ce fix(app): link background subagents to their sessions (#47455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-09 09:39:22 +08:00
Brendan Allan d39290fbb9 feat(desktop): add SSH server connections (#47753) 2026-09-09 09:32:38 +08:00
Aiden Cline 6ee2ed7510 refactor(session): remove message content mutation API (#48043) 2026-09-08 20:27:22 -05:00
Dax 594635b5ae feat(cli): add auth account switching and targeted logout (#48050) 2026-09-08 21:27:21 -04:00
David Hill 18b05e86fb feat(app): add browser shortcut to new-tab menu (#48044) 2026-09-09 00:03:10 +00:00
Dax cc501650c6 refactor(cli): remove console command (#48040) 2026-09-08 19:54:47 -04:00
David HillandLukeParkerDev 37b6fbc5bf fix(app): move vertical tab update button to footer (#48030)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-09 09:41:10 +10:00
Aiden Cline d24f8b0810 fix(core): discourage polling background shell commands (#48041) 2026-09-08 18:34:59 -05:00
Aiden Cline 79a6a90862 fix(plugin): normalize promise API inputs (#48039) 2026-09-08 18:34:49 -05:00
David Hill 8c5eca5bb2 fix(session-ui): show one count before used (#48038) 2026-09-09 09:15:24 +10:00
David Hill f3128fa241 fix(ui): unify composer submit button styles (#48032) 2026-09-09 09:07:41 +10:00
David Hill 883d16d2ad fix(session-ui): emphasize mentions and soften at prefixes (#48029) 2026-09-09 09:05:13 +10:00
Dax Raad 5c30292daa fix(update): restore next channel compatibility alias 2026-09-08 16:57:16 -04:00
Aiden Cline 11aee1b49f fix(codegen): preserve equivalent suffixed type references (#48024) 2026-09-08 15:21:17 -05:00
Dax Raad a322d7f98f fix(update): bind migrated singular D1 database 2026-09-08 15:57:51 -04:00
Aiden Cline 546b9fb3c1 refactor(codemode): consolidate duplicated interpreter helpers (#48009) 2026-09-08 14:46:16 -05:00
Aiden Cline 83288921f6 fix(core): map Bedrock OpenAI reasoning effort for inference-profile model IDs (#48006) 2026-09-08 14:42:33 -05:00
opencode-agent[bot] 510b771946 chore: update nix node_modules hashes 2026-09-08 18:28:38 +00:00
Dax Raad 78915a0f17 refactor(update): rename updates service to update 2026-09-08 14:10:28 -04:00
Adam 1ead17547b fix(core): name hosted provider opencode web search (#48001) 2026-09-08 12:28:12 -05:00
Aiden Cline 0c1bf08ca6 feat(core): add Poe browser OAuth (#47883) 2026-09-08 12:15:17 -05:00
Aiden Cline 8b09f6415a refactor(codemode): export runtime value classes as Values (#48000) 2026-09-08 12:11:34 -05:00
Adam e791afdfa3 feat(core): add console web search (#47293) 2026-09-08 12:08:53 -05:00
Dax Raad e655fed6c3 fix(updates): reject retired next channel 2026-09-08 13:06:08 -04:00
ccbc018072 fix(tui): honor configured worktree strategy (#47991)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-08 12:46:57 -04:00
James Long 4432956490 fix(worktree): accept the strategy's returned directory (#47997) 2026-09-08 12:45:57 -04:00
Dax Raad 375bf4908f feat(updates): configure per-channel gradual rollouts 2026-09-08 12:34:59 -04:00
James Long cc6bff39a0 feat(tui): manage worktrees and explicitly move sessions (#47984) 2026-09-08 12:20:25 -04:00
James Long 8a5709324f fix(worktree): resolve configured paths relative to project (#47990) 2026-09-08 12:03:53 -04:00
James Long be58ca806c fix(cli): recover dev hot reloads and preserve routes (#47979) 2026-09-08 11:19:51 -04:00
Dax 9e42e5cc4c feat(core): refresh console provider config periodically (#47980) 2026-09-08 11:17:12 -04:00
opencode-agent[bot] c2a1649dd4 chore: update nix node_modules hashes 2026-09-08 14:19:25 +00:00
James Long ded9c7e505 feat(cli): add Vite-powered TUI development entrypoint (#47950) 2026-09-08 10:00:16 -04:00
f9bc2233dd fix(app): align desktop agent and model switching (#47286)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 18:16:08 +08:00
Simon Klee 7487999e06 tabs: add compact session tab rail (#47938) 2026-09-08 12:11:16 +02:00
Simon Klee 2eea36e731 mini: add more minimal output presets. (#47931) 2026-09-08 11:53:02 +02:00
Simon Klee 4fef8edbe8 mini: add clear-screen command (#47928) 2026-09-08 11:24:36 +02:00
Simon Klee 50c552f763 tui: add tool filtering option to Markdown exports (#47929) 2026-09-08 11:24:29 +02:00
Luke Parker a3d5923aca fix(session-ui): stop refetching missing shell output (#47926) 2026-09-08 09:12:15 +00:00
Luke Parker ea2c0184ce fix(app): release attachment blobs when no draft references them (#47922) 2026-09-08 09:11:59 +00:00
Luke Parker 09c318094c fix(app): bound terminal snapshot serialization on teardown (#47924) 2026-09-08 09:04:17 +00:00
Luke Parker 22a534a0bb fix(desktop): skip differential updates when the cache is stale (#47925) 2026-09-08 09:03:14 +00:00
opencode-agent[bot]andBrendonovich c3f1bdaf97 fix(app): cap worktree picker height (#47899)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 14:52:59 +08:00
opencode-agent[bot] 2bf9bec897 chore: update nix node_modules hashes 2026-09-08 06:51:42 +00:00
Brendan Allan 90dd682e66 feat(app): configure initial servers and add QR pairing (#47799) 2026-09-08 14:32:17 +08:00
opencode-agent[bot]andBrendonovich 64684b118f fix(app): focus auto-created terminals (#47890)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 14:31:06 +08:00
Dax cab8e39ad5 fix(app): use HTTP-safe attachment and mutation IDs (#47887) 2026-09-08 05:07:37 +00:00
Aiden Cline 5165d6008c fix(ai): default newer Claude models to drop invalid thinking (#47884) 2026-09-07 23:46:10 -05:00
Aiden Cline 4d74854e8c trim redundant opencode instruction (#47878) 2026-09-07 23:16:42 -05:00
Dax Raad 50e17b7f95 fix(desktop): use OpenCode-hosted update releases 2026-09-08 00:04:10 -04:00
Aiden Cline e8177238f6 feat(core): support native Snowflake Cortex authentication (#47156) 2026-09-07 22:50:50 -05:00
Aiden Cline b3f36c0967 feat(ai): add Z.AI language models (#47866) 2026-09-07 21:55:33 -05:00
Kit Langton 1f77408ff2 feat(tui): navigate projects and worktrees
Add project and worktree navigation with restored search and selection, workspace-preserving targets, and optional worktree naming. Keep creation in the Ctrl+N footer and defer filesystem browsing.
2026-09-07 22:35:52 -04:00
Aiden Cline a912a6ee4f fix(core): clarify shell background parameter guidance (#47865) 2026-09-07 20:16:18 -05:00
Aiden Cline 2ac698d65a feat(ai): add Moonshot provider (#47851) 2026-09-07 19:50:55 -05:00
opencode-agent[bot] c1c6ab593d chore: update nix node_modules hashes 2026-09-07 23:40:48 +00:00
Dax Raad d1d1c6f890 feat(release): publish package binaries through Cloudflare
Move files deployments to Wrangler, make CLI and desktop own their publishing destinations, add direct-download update metadata and desktop feeds, and refresh installation docs.
2026-09-07 19:17:29 -04:00
opencode-agent[bot] 0ab661a9cc chore: update nix node_modules hashes 2026-09-07 22:53:49 +00:00
Dax Raad a55dc8c84a feat(services): organize hosted services and add public files 2026-09-07 18:34:34 -04:00
opencode-agent[bot] be41bc4e7d fix(app): keep tab progress visible on hover (#47835) 2026-09-07 22:23:02 +00:00
Dax 567f8b9743 feat(updates): serve updates under opencode.ai/update (#47858) 2026-09-07 18:18:32 -04:00
Dax 6263a35b3f fix(cli): install only opencode for stable AUR releases (#47857) 2026-09-07 18:04:38 -04:00
Dax 74ca560c75 feat(cli): publish stable releases to opencode-bin on AUR (#47856) 2026-09-07 17:59:39 -04:00
Dax a68d6f904d feat(cli): publish beta releases to AUR (#47855) 2026-09-07 17:52:48 -04:00
opencode-agent[bot] cc8c2f8810 chore: update nix node_modules hashes 2026-09-07 21:41:05 +00:00
Dax Raad ad31bff969 docs: remove internal scope migration checklist 2026-09-07 17:22:10 -04:00
Dax a5312e169b refactor(packages): migrate to the opencode npm scope (#47852) 2026-09-07 17:19:33 -04:00
opencode-agent[bot] 16aca14bc7 chore: update nix node_modules hashes 2026-09-07 21:09:25 +00:00
Dax Raad 4aba093c98 fix(updates): scope minimum checks to the caller channel 2026-09-07 16:51:37 -04:00
Dax Raad a3bbcd5c73 fix(updates): respect the default CLI user agent 2026-09-07 16:49:59 -04:00
Dax Raad c05d07cd73 feat(updates): gate releases on minimum client versions 2026-09-07 16:47:01 -04:00
Aiden Cline ef34ada9fb feat(core): add DigitalOcean OAuth and router discovery (#47137) 2026-09-07 15:41:09 -05:00
Dax Raad e15fb426ec fix(browser): publish plugin under opencode scope 2026-09-07 16:27:53 -04:00
Aiden Cline 72433f2ed8 feat(ai): add Meta provider (#47826) 2026-09-07 15:13:48 -05:00
opencode-agent[bot]andJay b32d8c3e58 chore(app): update GitHub star count (#47844)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-09-07 15:53:56 -04:00
Aiden Cline 6af8515f69 feat(ai): add MiniMax provider (#47827) 2026-09-07 14:14:14 -05:00
Filip 5c50edb9bb feat(core): expose session rename tool (#47837) 2026-09-07 18:38:26 +00:00
Aiden Cline fcddc84225 fix(codemode): render empty tools as () and accept zero args (#47833) 2026-09-07 12:52:48 -05:00
opencode-agent[bot]andrekram1-node 582a2108ce fix(tui): finish reasoning rows on end event (#47813)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-07 11:12:33 -05:00
OpeOginniandAiden Cline 9c65a69937 fix(core): support granular webfetch permissions (#46611)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-07 11:03:21 -05:00
Aiden Cline 1d391908f4 feat(core): identify to MCP authorization servers with a client metadata document (#47743) 2026-09-07 10:54:29 -05:00
Filip 596dca4dee feat(cli): add session list and delete commands (#47812) 2026-09-07 17:43:56 +02:00
Shoubhit Dash 1827832775 fix(core): retry transient provider compaction failures (#47806) 2026-09-07 21:03:43 +05:30
Shoubhit Dash 898692af26 feat(core): schedule provider compaction automatically (#47324) 2026-09-07 19:27:11 +05:30
Kit Langton 5c3f2ddf8c refactor(core): unify filesystem access policy (#47630) 2026-09-07 09:26:55 -04:00
Shoubhit Dash 1382cebe10 feat(core): support explicit provider compaction (#47323) 2026-09-07 18:20:35 +05:30
Shoubhit Dash 0732cdd8e1 feat(core): persist provider compaction context (#47322) 2026-09-07 18:16:16 +05:30
opencode-agent[bot]andnexxeln 2bf6a9813a fix(tui): honor falsy dialog current values (#47782)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-07 17:21:06 +05:30
opencode-agent[bot]andnexxeln f094a10645 fix(plugin): expose dialog option footer (#47780)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-07 17:20:51 +05:30
Luke Parker 89f1943d2d fix(app): restore the review panel landmark label (#47781) 2026-09-07 21:27:13 +10:00
opencode-agent[bot] fc4f319676 chore: update nix node_modules hashes 2026-09-07 11:16:33 +00:00
Shoubhit Dash b51fdb331c fix: delete sessions with missing directories and report api errors (#47774) 2026-09-07 16:34:17 +05:30
Luke Parker 8a1a6b8fb4 feat(desktop): add browser tabs and Chromium diagnostics (#44838) 2026-09-07 20:58:02 +10:00
Victor Navarro 1a2b985777 refactor(core): remove legacy Console variant normalization (#47760) 2026-09-07 10:49:36 +02:00
opencode-agent[bot] 00067d23a0 chore: update nix node_modules hashes 2026-09-07 07:36:24 +00:00
Luke Parker 3ebc4b01b9 feat(browser): add a public-API browser plugin (#46531) 2026-09-07 17:16:01 +10:00
Brendan Allan 17f362e0fe fix(desktop): brand macOS dev app as OpenCode Dev (#47744) 2026-09-07 14:45:57 +08:00
opencode-agent[bot]andrekram1-node 5b83c15fb5 fix(core): route MCP skill authentication through the UI (#47738)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-07 01:38:52 -05:00
Luke Parker d86c3041c9 fix(app): keep new session open while the worktree list loads (#47734) 2026-09-07 05:56:23 +00:00
2134 changed files with 45552 additions and 13681 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
---
"@opencode-ai/core": patch
"@opencode/core": patch
---
Correct directory page headings when the read offset is zero.
+34
View File
@@ -0,0 +1,34 @@
name: deploy-files
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-files-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/files
run: bun typecheck
- name: Deploy
working-directory: services/files
run: bun run deploy --env ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+2 -2
View File
@@ -24,13 +24,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
working-directory: services/www
run: bun run build
env:
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
working-directory: services/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -11,6 +11,7 @@ on:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "services/*/package.json"
- "flake.lock"
- "nix/node_modules.nix"
- "nix/scripts/**"
+4 -14
View File
@@ -48,7 +48,7 @@ jobs:
- name: Deploy update service
if: github.ref_name == 'v2'
working-directory: packages/updates
working-directory: services/update
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -670,19 +670,6 @@ jobs:
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Upload desktop release assets
if: needs.version.outputs.release
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
shopt -s nullglob
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
if (( ${#files[@]} == 0 )); then
echo "No desktop release assets found"
exit 1
fi
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
- run: ./script/publish.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -694,3 +681,6 @@ jobs:
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+4 -4
View File
@@ -49,7 +49,7 @@ jobs:
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
@@ -132,10 +132,10 @@ jobs:
timeout-minutes: 15
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
bun turbo verify:package --filter=@opencode/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
bun turbo verify:package --affected --filter=@opencode/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
@@ -173,7 +173,7 @@ jobs:
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/www
working-directory: services/www
run: bun run check:generated
e2e:
+1 -1
View File
@@ -1,5 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import { tool } from "@opencode/plugin"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
+1 -1
View File
@@ -1,5 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import { tool } from "@opencode/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
+2 -2
View File
@@ -84,9 +84,9 @@ const { a, b } = obj
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
### Variables
+8
View File
@@ -0,0 +1,8 @@
# Slash-command follow-up behavior
Both screenshots submit `/review current changes` with the follow-up preference set to **Queue** while a session is running.
- `before.png`: production build of `v2` at `1dcc6551d9`; the command is sent as Steer.
- `after.png`: production build with this fix; the command appears in the queue above the composer.
Captured with the fixture-backed `slash commands respect queue preference and alternate submit` Playwright scenario in `packages/app/e2e/regression/session-queue.spec.ts`. The production UI is rendered against an isolated command/session fixture.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+962 -622
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
[test]
root = "./do-not-run-tests-from-root"
+1
View File
@@ -6,6 +6,7 @@ export function createWebApp(domain: string) {
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_OPENCODE_SERVER_MODE: "none",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
+12 -4
View File
@@ -165,22 +165,30 @@ else
exit 1
fi
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
if [ -z "$specific_version" ]; then
if [ -z "$specific_version" ] || [ -z "$package" ]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
package_scope="${package%/cli}"
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
specific_version=$requested_version
fi
package_name="@opencode-ai/cli-$target"
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/@opencode-ai%2fcli-$target/$specific_version" || true)
package_name="$package_scope/cli-$target"
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/$package_scope%2fcli-$target/$specific_version" || true)
# Older clients install the minimum release before they can migrate package names.
if [ "$http_status" = "404" ] && [ -n "$requested_version" ]; then
package_name="@opencode-ai/cli-$target"
http_status=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/@opencode-ai%2fcli-$target/$specific_version" || true)
fi
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Version ${specific_version} is not available for $target${NC}"
echo -e "${MUTED}Available versions: https://www.npmjs.com/package/$package_name?activeTab=versions${NC}"
+1 -1
View File
@@ -88,7 +88,7 @@ stdenv.mkDerivation (finalAttrs: {
cd packages/desktop
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
}
}
+2 -1
View File
@@ -27,11 +27,12 @@ stdenvNoCC.mkDerivation {
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../services
../bun.lock
../package.json
../patches
../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
../.github/TEAM_MEMBERS # required by @opencode/script
]
);
};
+6 -3
View File
@@ -9,11 +9,13 @@
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:www": "bun run --cwd services/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
@@ -34,6 +36,7 @@
"workspaces": {
"packages": [
"packages/*",
"services/*",
"packages/console/*",
"packages/stats/*"
],
@@ -128,8 +131,8 @@
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/script": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
+3 -4
View File
@@ -13,6 +13,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
@@ -81,8 +82,6 @@ export const route = Route.make({
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
LLM `ProviderMetadata` is indexed by the selected `LanguageModel.route.id`. Use that same route ID when emitting usage, reasoning signatures, item IDs, and hosted-tool state and when reading them for continuation. There is no independently configurable metadata namespace. Reassigning a provider does not rename the route or its metadata; changing the route ID changes both. Provider-specific APIs with different replay formats must have distinct route IDs.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
@@ -123,10 +122,10 @@ Keep provider facades small and explicit:
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
+343 -62
View File
@@ -1,12 +1,12 @@
# @opencode-ai/ai
# @opencode/ai
Schema-first language model and image-generation APIs built with Effect.
```ts
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode-ai/ai"
import { RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
import { LLM, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
@@ -29,13 +29,323 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
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.
## Alibaba Cloud Model Studio
`Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select
Chat Completions (`.model` or `.chat`), Anthropic-compatible Messages (`.messages`), or OpenAI-compatible
Responses (`.responses`). These routes use HTTP/SSE.
```ts
import { LLM } from "@opencode/ai"
import { Alibaba } from "@opencode/ai/providers"
const alibaba = Alibaba.configure({
region: "ap-southeast-1", // Singapore
apiKey: process.env.DASHSCOPE_API_KEY,
// workspaceID: "llm-your-workspace", // use a workspace-dedicated endpoint
})
const request = LLM.request({
model: alibaba.model("qwen3.8-max"),
prompt: "Explain this design.",
providerOptions: { reasoningEffort: "medium" },
})
```
### Regions and credentials
| Region | `region` | Shared host when `workspaceID` is omitted |
| ------------------- | ---------------- | ----------------------------------------- |
| Singapore | `ap-southeast-1` | `dashscope-intl.aliyuncs.com` |
| China (Beijing) | `cn-beijing` | `dashscope.aliyuncs.com` |
| China (Hong Kong) | `cn-hongkong` | `cn-hongkong.dashscope.aliyuncs.com` |
| US (Virginia) | `us-east-1` | `dashscope-us.aliyuncs.com` |
| Germany (Frankfurt) | `eu-central-1` | Supply `workspaceID` or `baseURL` |
| Japan (Tokyo) | `ap-northeast-1` | Supply `workspaceID` or `baseURL` |
With `workspaceID`, the host is `{workspaceID}.{region}.maas.aliyuncs.com`. A complete `baseURL`
overrides regional setup, including the API prefix: `/compatible-mode/v1` for Chat/Responses,
or `/apps/anthropic/v1` for Messages. The selector appends its operation path.
Keys and model availability are region-specific. Auth resolves from explicit `auth` or `apiKey`,
then `DASHSCOPE_API_KEY`, then `ALIBABA_API_KEY`.
The access region and inference scope differ: Virginia's `-us` model IDs request US-only inference;
some regions select scope through their workspace. Model IDs pass through unchanged.
Alibaba's [regional guide](https://www.alibabacloud.com/help/en/model-studio/regions) and
[base URL table](https://www.alibabacloud.com/help/en/model-studio/base-url) disagree about Virginia's
shared host; the entry above follows the base URL table. Dedicated hosts can be copied from the console.
### Native options
- **Chat:** `reasoningEffort``reasoning_effort`, `enableThinking``enable_thinking`,
`thinkingBudget``thinking_budget`, and `preserveThinking``preserve_thinking`.
Replay complete `response.message` values to retain `reasoning_content` separately from answer text.
Qwen 3.8 defaults to preserving thinking; older models have different defaults.
Additional options include `toolStream`, `parallelToolCalls`, `repetitionPenalty`, `responseFormat`,
`enableSearch`, and native `searchOptions`. `generation.topK` lowers to `top_k`.
`clearThinking` is a hosted GLM control, and `thinking.type` is available for hosted MiniMax models.
- **Messages:** `effort``output_config.effort`. `thinking.type` accepts enabled/disabled with an
optional `budgetTokens` (or native `budget_tokens`). `outputConfig.format` accepts a JSON schema.
Model Studio's empty thinking signatures are accepted; supplied signatures are replayed unchanged.
- **Responses:** `reasoningEffort``reasoning.effort`, plus `enableThinking`, `store`,
`previousResponseId`, and `conversation`. Omitted `store` retains the API's default (`true`);
set it to `false` for client-managed history. `previousResponseId` requires a stored response.
Hosted tools are `Alibaba.webSearch()`, `Alibaba.webExtractor()`, and `Alibaba.codeInterpreter()`.
Web extraction is used together with web search. Hosted calls/results carry `providerExecuted: true`.
Omitted options preserve provider defaults. Effort values pass through unchanged and accept future
strings. Qwen 3.8 Chat rejects requests combining a thinking budget with effort.
Package entrypoints are `@opencode/ai/providers/alibaba`, `alibaba/chat`, `alibaba/messages`,
and `alibaba/responses`. Live recordings cover all three APIs in Singapore; regional URL construction
is unit-tested for all six regions.
## Z.AI
`ZAI` uses the standard API. Chat Completions is the default language-model API;
the existing `.image(...)` selector provides image generation.
```ts
import { LLM } from "@opencode/ai"
import { ZAI, ZAICodingPlan } from "@opencode/ai/providers"
const zai = ZAI.configure({ apiKey: process.env.ZAI_API_KEY })
const request = LLM.request({
model: zai.model("glm-5.3"), // also zai.chat("glm-5.3")
prompt: "Explain this design.",
providerOptions: {
reasoningEffort: "high",
thinking: { type: "enabled", clear_thinking: false },
},
})
const coding = ZAICodingPlan.configure({ apiKey: process.env.ZAI_API_KEY })
const messages = LLM.request({
model: coding.messages("glm-5.3"),
prompt: "Explain this design.",
providerOptions: { effort: "high" },
})
```
The products have distinct provider identities and endpoints:
| Provider | Selector | Default base URL |
| ----------------------------------- | --------------------------- | ------------------------------------- |
| `ZAI` (`zai`) | `.model`, `.chat`, `.image` | `https://api.z.ai/api/paas/v4` |
| `ZAICodingPlan` (`zai-coding-plan`) | `.model`, `.chat` | `https://api.z.ai/api/coding/paas/v4` |
| `ZAICodingPlan` | `.messages` | `https://api.z.ai/api/anthropic/v1` |
| `ZAICodingPlan` | `.responses` | `https://api.z.ai/api/v1` |
Both read `ZAI_API_KEY` when `apiKey` is omitted and support an explicit `auth` override.
Coding Plan requires an active subscription. `baseURL` overrides the selected API's
complete base, including its version prefix. Language-model routes use HTTP/SSE.
Options retain the selected API's native semantics:
- Chat `reasoningEffort` lowers to `reasoning_effort`; Responses lowers it to `reasoning.effort`.
Messages `effort` lowers to `output_config.effort`. Omission preserves provider defaults.
- Chat `thinking` passes `type` and `clear_thinking` through unchanged. Set
`clear_thinking: false` and replay complete `response.message` values to preserve reasoning
across user messages and tool loops. The standard API defaults to clearing historical thinking;
Coding Plan documents preservation by default.
- Messages accepts `thinking: { type: "enabled" | "adaptive" | "disabled" }` without requiring
an Anthropic token budget. Coding Plan documents a disabled toggle as low-effort thinking
for GLM-5.3, with explicit effort taking precedence.
- Chat also offers `toolStream`, `doSample`, `responseFormat`, `requestID`, and `userID`.
Tool-argument streaming is enabled when tools are present on GLM-4.6/4.7/5.x;
`toolStream: false` explicitly disables it. Older model families omit the opt-in.
- Effort and thinking values remain forward-compatible strings. Their meaning is model-specific:
GLM-5.3 accepts `low`, `high`, and `max` effort and rejects disabled thinking with HTTP 400;
the direct GLM-5.2 recordings returned reasoning even with `none` and `minimal` effort,
whereas explicit `thinking.type: "disabled"` disabled it on GLM-5.2 and GLM-4.7.
Standard API recordings cover GLM-5.3 efforts and a full preserved-reasoning tool loop with
a subsequent user follow-up, GLM-5.2 efforts, older-model thinking toggles, GLM-4.5 tool calls,
GLM-5.3-Flash image input, and JSON output. Coding Plan has unit coverage for routing,
request options, and reasoning replay; successful live recordings are pending.
Package entrypoints are `@opencode/ai/providers/zai`, `zai/chat`, `zai-coding-plan`,
`zai-coding-plan/chat`, `zai-coding-plan/messages`, and `zai-coding-plan/responses`.
## Moonshot
Moonshot defaults to Chat Completions, with Messages and Responses selectors for Kimi K3:
```ts
import { LLM } from "@opencode/ai"
import { Moonshot } from "@opencode/ai/providers"
const moonshot = Moonshot.configure({ apiKey: process.env.MOONSHOT_API_KEY })
const request = LLM.request({
model: moonshot.model("kimi-k3"), // also moonshot.chat("kimi-k3")
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
const messages = LLM.request({
model: moonshot.messages("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { effort: "high" },
})
const responses = LLM.request({
model: moonshot.responses("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
```
When `apiKey` is omitted, authentication reads `MOONSHOT_API_KEY`, then `MOONSHOTAI_API_KEY`.
Chat and Responses use `https://api.moonshot.ai/v1`; Messages uses
`https://api.moonshot.ai/anthropic/v1`. `baseURL` overrides the selected API's complete base,
including the version prefix, for regional endpoints or gateways. Each endpoint requires its own valid credentials.
All three routes use HTTP/SSE.
Reasoning options stay native to the selected API and model:
| Model/API | Provider options |
| --------------------------- | --------------------------------------------------------------------------------------- |
| K3 Chat / Responses | `reasoningEffort: "low" \| "high" \| "max"`; default is `max` |
| K3 Messages | `effort: "low" \| "high" \| "max"`; default is `max` |
| K2.6 Chat | `thinking: { type: "enabled" \| "disabled", keep?: "all" \| null }`; default is enabled |
| K2.7 Code / high-speed Chat | Omit `thinking` to use always-on, preserved reasoning |
Omitting options preserves the model's defaults. K3 uses effort rather than the K2.x `thinking`
parameter. Known effort values have autocomplete while future strings remain accepted.
For K2.6, `thinking.keep: "all"` enables preservation of reasoning across user messages.
K3 and both K2.7 Code variants always preserve reasoning. Continue with the returned
`response.message` and matching tool results so reasoning content and any Messages signatures are retained.
Leave sampling options such as `temperature` unset to use these models' fixed defaults.
The recorded suite covers all three K3 APIs, default and explicit efforts, K2.6 thinking modes,
both K2.7 Code variants, generated tool loops with a subsequent user follow-up, required/disabled
tool choice, image-byte input, and native structured output through `http.body` overlays.
K3 Chat and Messages accept required and disabled tool choice. Responses supports automatic tool
choice only; explicit `required` and `none` produce a provider `InvalidRequest` error, also covered by recordings.
The provider targets the Moonshot Open Platform; Kimi Code is a separate product and endpoint.
Package entrypoints are `@opencode/ai/providers/moonshot`, `moonshot/chat`, `moonshot/messages`,
and `moonshot/responses`; each exports `model(modelID, settings)`.
## MiniMax
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
```ts
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { MiniMax } from "@opencode/ai/providers"
import { RequestExecutor } from "@opencode/ai/route"
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
const request = LLM.request({
model: minimax.model("MiniMax-M3"), // also minimax.messages("MiniMax-M3")
prompt: "What is 173 multiplied by 219?",
providerOptions: { thinking: { type: "adaptive" } },
generation: { maxTokens: 1536 },
})
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
console.log(response.text)
```
Select `minimax.chat("MiniMax-M3")` or `minimax.responses("MiniMax-M3")` for MiniMax's native Chat Completions
and Responses APIs. The matching package entrypoints are `@opencode/ai/providers/minimax/messages`,
`@opencode/ai/providers/minimax/chat`, and `@opencode/ai/providers/minimax/responses`.
- **Messages:** M3 thinking defaults off. Set `thinking: { type: "adaptive" }` to enable it or
`thinking: { type: "disabled" }` to disable it.
- **Chat:** M3 thinking defaults on and uses the same `thinking` control. The provider enables `reasoning_split`
by default so reasoning is separate from answer text; `reasoningSplit: false` selects native `<think>`-tagged text.
- **Responses:** M3 reasoning defaults off. `reasoningEffort: "none"` disables it; `"minimal"`, `"low"`,
`"medium"`, and `"high"` enable reasoning without changing its depth.
M2.x models always think, even when a disabling option is supplied. For tool continuations, retain the complete
`response.message` in history before adding `Message.tool(...)` results; this preserves reasoning and any signatures.
The default API bases are `https://api.minimax.io/anthropic/v1` for Messages and `https://api.minimax.io/v1` for
Chat and Responses. `configure({ baseURL })` replaces the selected API's base, including its version prefix.
## Meta
Use Meta's direct [Model API](https://dev.meta.ai/docs/overview) with `META_API_KEY`:
```ts
import { Meta } from "@opencode/ai/providers"
const meta = Meta.configure() // or Meta.configure({ apiKey })
const request = LLM.request({
model: meta.responses("muse-spark-1.3"), // meta.model(...) also selects Responses
prompt: "What is 173 multiplied by 219? Reply with the integer.",
providerOptions: { reasoningEffort: "low" },
generation: { maxTokens: 1024 },
})
```
`meta.chat("muse-spark-1.3")` selects Chat Completions; `meta.messages("muse-spark-1.3")` selects
the Anthropic-compatible Messages API. All use `https://api.meta.ai/v1`. The package entrypoints
`@opencode/ai/providers/meta/responses`, `meta/chat`, and `meta/messages` expose `model(modelID, settings)`.
[Muse Spark](https://dev.meta.ai/docs/models) supports `minimal`, `low`, `medium`, `high`, and
`xhigh` reasoning effort; standard-tier 1.3 also supports `max`. Omitting effort uses the model's
default. Muse Spark always reasons and rejects `none`. The output-token budget includes private reasoning.
Responses defaults to `store: false` and `include: ["reasoning.encrypted_content"]`. Preserve
`response.message` along with matching `Message.tool(...)` results in subsequent requests to replay
reasoning through tool loops. Optional `reasoningSummary: "auto"` requests a readable summary.
For server-managed history, override `store: true, include: []` and send the response ID through
`http: { body: { previous_response_id: responseID } }` with only the new input.
Chat Completions redacts private reasoning and cannot carry it between calls.
Responses and Chat support only `toolChoice: "auto"` (the default). Messages also accepts `"none"`;
its documented forced `"any"` choice currently returns HTTP 400. Messages defaults to adaptive thinking
with `display: "omitted"`, preserving encrypted `redacted_thinking` in `response.message`. Use
`providerOptions: { effort: "low" }` for depth or `thinking: { type: "enabled", budgetTokens: 1024 }`
for budget compatibility (with `generation.maxTokens > 1024`).
Add `tools: [Meta.webSearch()]` to a Spark Responses or Messages request for hosted web search.
Responses exposes hosted results and URL citations in text-part `providerMetadata.meta.annotations`.
To include search result lists, set `include: ["reasoning.encrypted_content", "web_search_call.results"]`.
Messages exposes hosted search calls; the recorded Messages API stream does not supply structured
citations or separate result blocks. Retain `response.message` for either API's continuation.
Use `Image.generate` for one-off generation or editing:
```ts
import { Image, ImageInput } from "@opencode/ai"
const generation = Image.generate({
model: meta.image("muse-image-1.0"),
prompt: "A flat black square on a white background.",
options: { n: 1, reasoningStrength: "low" },
})
const edit = Image.generate({
model: meta.image("muse-image-1.0"),
prompt: "Make the square purple.",
images: [ImageInput.bytes(imageBytes, "image/webp")],
options: { outputFormat: "png", reasoningStrength: "low" },
})
```
The default image format is WEBP; `outputFormat` also accepts PNG/JPEG and `responseFormat: "url"`
returns a signed URL. `size` is an aspect-ratio hint. For conversational images, select
`meta.responses("muse-image-1.0")` with `tools: [Meta.imageGeneration({ reasoningStrength: "low" })]`.
Generated images are provider-executed tool results with file content. Retain `response.message` to
replay the signed image handle on the next request. Muse Image accepts only the `image_generation` tool.
Meta Responses is explicitly HTTP/SSE-only and does not use WebSockets, even when a caller supplies
`StreamOptions.webSocket`. The public `/v1/responses` endpoint rejects WebSocket upgrades with HTTP 405 (`Allow: POST`).
## 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"
import { Image, ImageInput } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -131,7 +441,7 @@ yield *
Google's current Gemini image models use the same direct API:
```ts
import { Google } from "@opencode-ai/ai/providers"
import { Google } from "@opencode/ai/providers"
const googleProgram = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -207,12 +517,12 @@ The hosted result is represented as a provider-executed tool call and tool resul
## Testing
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
Use the deterministic test client from `@opencode/ai/testing` to script provider-neutral responses and inspect
the requests sent by code under test:
```ts
import { Effect } from "effect"
import { TestLLM } from "@opencode-ai/ai/testing"
import { TestLLM } from "@opencode/ai/testing"
const programWithTestClient = Effect.gen(function* () {
const test = yield* TestLLM.Test
@@ -323,8 +633,8 @@ This capability describes protocol implementation, **not universal availability
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
```ts
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
@@ -344,7 +654,7 @@ const next = LLMRequest.update(request, {
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
```ts
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
import { CompactionPart, ProviderID } from "@opencode/ai"
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
@@ -450,7 +760,7 @@ Normalized cache usage is read back into `response.usage.cacheReadInputTokens` a
Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime.
```ts
import { OpenAI, CloudflareAIGateway } from "@opencode-ai/ai/providers"
import { OpenAI, CloudflareAIGateway } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
const gateway = CloudflareAIGateway.configure({
@@ -464,7 +774,7 @@ Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazo
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
```ts
import { DeepSeek, Fireworks } from "@opencode-ai/ai/providers"
import { DeepSeek, Fireworks } from "@opencode/ai/providers"
const deepseek = DeepSeek.configure({ apiKey }).model("deepseek-chat")
const fireworks = Fireworks.configure({ apiKey }).model("accounts/fireworks/models/my-model")
@@ -474,10 +784,10 @@ The former `OpenAICompatible.baseten`, `.cerebras`, `.deepinfra`, `.deepseek`, `
### Provider entrypoints
Provider modules are available through dedicated exports from `@opencode-ai/ai`. Each LLM entrypoint exports `model(modelID, settings)`, where `settings` contains provider configuration plus common `headers` and `body` overlays.
Provider modules are available through dedicated exports from `@opencode/ai`. Each LLM entrypoint exports `model(modelID, settings)`, where `settings` contains provider configuration plus common `headers` and `body` overlays.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
@@ -487,14 +797,14 @@ const selected = model("gpt-5", {
APIs have separate entrypoints:
- `@opencode-ai/ai/providers/openai/chat`
- `@opencode-ai/ai/providers/openai/responses`
- `@opencode-ai/ai/providers/openai-compatible/responses`
- `@opencode-ai/ai/providers/anthropic-compatible`
- `@opencode-ai/ai/providers/google-vertex/gemini`
- `@opencode-ai/ai/providers/google-vertex/chat`
- `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages`
- `@opencode/ai/providers/openai/chat`
- `@opencode/ai/providers/openai/responses`
- `@opencode/ai/providers/openai-compatible/responses`
- `@opencode/ai/providers/anthropic-compatible`
- `@opencode/ai/providers/google-vertex/gemini`
- `@opencode/ai/providers/google-vertex/chat`
- `@opencode/ai/providers/google-vertex/responses`
- `@opencode/ai/providers/google-vertex/messages`
OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
@@ -503,65 +813,36 @@ Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate A
Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only.
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
import { model } from "@opencode/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
import { model } from "@opencode/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
import { model } from "@opencode/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
import { model } from "@opencode/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
```
Additional provider entrypoints include:
- `@opencode-ai/ai/providers/baseten`
- `@opencode-ai/ai/providers/deepseek`
- `@opencode-ai/ai/providers/fireworks`
- `@opencode-ai/ai/providers/cloudflare-ai-gateway`
- `@opencode-ai/ai/providers/cloudflare-workers-ai`
### Route-keyed metadata
LLM messages, events, and usage retain provider-specific information under `providerMetadata[model.route.id]`. The provider ID identifies the provider; the route ID identifies its API and owns the metadata needed to replay that API's responses.
```ts
const model = GoogleVertexMessages.configure({ project, accessToken }).model("claude-sonnet-4-5")
const response = yield * LLM.generate(LLM.request({ model, prompt: "Think through this problem." }))
const reasoning = response.message.content.find((part) => part.type === "reasoning")
const signature = reasoning?.providerMetadata?.[model.route.id]?.signature
// model.route.id === "google-vertex-messages"
```
The former `providerMetadataKey` route option has been removed. Existing application-owned LLM metadata must use route keys instead of provider names, for example:
| API | Metadata key |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| OpenAI Chat / Responses | `openai-chat` / `openai-responses` |
| Azure Chat / Responses | `azure-openai-chat` / `azure-openai-responses` |
| Anthropic Messages | `anthropic-messages` |
| Google Gemini | `gemini` |
| Vertex Gemini / Chat / Responses / Messages | `google-vertex-gemini` / `google-vertex-chat` / `google-vertex-responses` / `google-vertex-messages` |
| Bedrock Converse / Mantle Chat / Mantle Responses | `bedrock-converse` / `bedrock-mantle-chat` / `bedrock-mantle-responses` |
| Groq / Mistral | `groq-chat` / `mistral-chat` |
| xAI Chat / Responses | `xai-chat` / `xai-responses` |
| Baseten / Cerebras / DeepInfra / DeepSeek / Fireworks / TogetherAI | `baseten-chat` / `cerebras-chat` / `deepinfra-chat` / `deepseek-chat` / `fireworks-chat` / `togetherai-chat` |
| Generic Chat / Responses | `openai-compatible-chat` / `openai-compatible-responses` |
OpenRouter and Cloudflare already use matching provider and route IDs. Generic Messages uses `anthropic-messages`. Custom routes use their own `id`.
- `@opencode/ai/providers/baseten`
- `@opencode/ai/providers/deepseek`
- `@opencode/ai/providers/fireworks`
- `@opencode/ai/providers/cloudflare-ai-gateway`
- `@opencode/ai/providers/cloudflare-workers-ai`
## Provider options & HTTP overlays
+3 -3
View File
@@ -1,7 +1,7 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
/**
* A runnable walkthrough of the LLM package use-site API.
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.20",
"name": "@opencode-ai/ai",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
"scripts": {
@@ -21,7 +21,7 @@
"devDependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -31,7 +31,7 @@
"@aws-sdk/credential-providers": "3.1057.0",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"@opencode-ai/schema": "workspace:*",
"@opencode/schema": "workspace:*",
"aws4fetch": "1.0.20",
"effect": "catalog:",
"google-auth-library": "10.5.0"
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { Script } from "@opencode/script"
import { $ } from "bun"
import { fileURLToPath } from "url"
+92
View File
@@ -0,0 +1,92 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LanguageModelCompatibility } from "../schema/index.js"
import { OpenAIChat } from "./openai-chat.js"
import { JsonObject, ProviderShared } from "./shared.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
clearThinking: Schema.optional(Schema.Boolean),
thinking: Schema.optional(
Schema.Struct({
type: Schema.declare<"adaptive" | "disabled" | (string & {})>(Schema.is(Schema.String)),
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
type: Schema.declare<"text" | "json_object" | "json_schema" | (string & {})>(Schema.is(Schema.String)),
json_schema: Schema.optional(JsonObject),
}),
),
enableSearch: Schema.optional(Schema.Boolean),
searchOptions: Schema.optional(
Schema.Struct({
forced_search: Schema.optional(Schema.Boolean),
search_strategy: Schema.optional(
Schema.declare<"turbo" | "max" | "agent" | "agent_max" | (string & {})>(Schema.is(Schema.String)),
),
enable_search_extension: Schema.optional(Schema.Boolean),
}),
),
})
export type OptionsInput = typeof Options.Type
export const compatibility = {
maxTokensField: "max_completion_tokens",
supportsStore: false,
supportsStrictMode: false,
reasoningField: "reasoning_content",
zaiToolStream: false,
} satisfies LanguageModelCompatibility
export const protocol = Protocol.make({
id: "alibaba-chat",
body: {
schema: Schema.Struct({
...OpenAIChat.bodyFields,
enable_thinking: Options.fields.enableThinking,
thinking_budget: Options.fields.thinkingBudget,
preserve_thinking: Options.fields.preserveThinking,
clear_thinking: Options.fields.clearThinking,
thinking: Options.fields.thinking,
parallel_tool_calls: Options.fields.parallelToolCalls,
repetition_penalty: Options.fields.repetitionPenalty,
top_k: Schema.optional(Schema.Int),
response_format: Options.fields.responseFormat,
enable_search: Options.fields.enableSearch,
search_options: Options.fields.searchOptions,
}),
from: Effect.fn("AlibabaChat.fromRequest")(function* (req) {
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
return {
...(yield* OpenAIChat.protocol.body.from(req)),
enable_thinking: opts.enableThinking,
thinking_budget: opts.thinkingBudget,
preserve_thinking: opts.preserveThinking,
clear_thinking: opts.clearThinking,
thinking: opts.thinking,
tool_stream: opts.toolStream,
parallel_tool_calls:
opts.parallelToolCalls ??
(req.toolChoice?.disableParallelToolUse === undefined ? undefined : !req.toolChoice.disableParallelToolUse),
repetition_penalty: opts.repetitionPenalty,
top_k: req.generation?.topK,
response_format: opts.responseFormat,
enable_search: opts.enableSearch,
search_options: opts.searchOptions,
}
}),
},
stream: OpenAIChat.protocol.stream,
})
export * as AlibabaChat from "./alibaba-chat.js"
@@ -0,0 +1,48 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { LLMRequest } from "../schema/index.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { ProviderShared } from "./shared.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
const Options = Schema.Struct({
effort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
thinking: Schema.optional(
Schema.Struct({
type: Schema.declare<"enabled" | "disabled" | (string & {})>(Schema.is(Schema.String)),
budgetTokens: Schema.optional(Schema.Int),
budget_tokens: Schema.optional(Schema.Int),
}),
),
})
export type OptionsInput = typeof Options.Type & Pick<AnthropicMessages.OptionsInput, "outputConfig">
export const protocol = Protocol.make({
id: "alibaba-messages",
body: {
schema: Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
thinking: Schema.optional(Schema.Struct({ type: Schema.String, budget_tokens: Schema.optional(Schema.Int) })),
}),
from: Effect.fn("AlibabaMessages.fromRequest")(function* (req) {
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
// Model Studio accepts enabled thinking without Anthropic's mandatory token budget.
return {
...(yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(req, {
providerOptions: { ...req.providerOptions, thinking: undefined },
}),
)),
thinking:
opts.thinking === undefined
? undefined
: {
type: opts.thinking.type,
budget_tokens: opts.thinking.budgetTokens ?? opts.thinking.budget_tokens,
},
}
}),
},
stream: AnthropicMessages.protocol.stream,
})
export * as AlibabaMessages from "./alibaba-messages.js"
@@ -0,0 +1,98 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
export type OptionsInput = typeof Options.Type
const NativeTool = Schema.Struct({ type: Schema.Literals(["web_search", "web_extractor", "code_interpreter"]) })
const WebExtractorItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.Literal("web_extractor_call"),
id: Schema.String,
urls: Schema.optional(Schema.Array(Schema.String)),
goal: Schema.optional(Schema.String),
}),
[JsonObject],
)
const Body = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, WebExtractorItem])),
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
enable_thinking: Options.fields.enableThinking,
previous_response_id: Options.fields.previousResponseId,
conversation: Options.fields.conversation,
stream: Schema.Literal(true),
})
const adapter = {
id: "alibaba-responses",
name: "Alibaba Responses",
nativeTool: (native) => ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(native.alibaba),
restoreHostedToolItem: (item: unknown) => (Schema.is(WebExtractorItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const tools = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
code_interpreter_call: { name: "code_interpreter", input: (item) => ({ code: item.code }) },
} satisfies ResponsesHostedTools.Definitions
export const protocol = Protocol.make({
id: adapter.id,
body: {
schema: Body,
from: Effect.fn("AlibabaResponses.fromRequest")(function* (req) {
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
const body = yield* OpenResponses.fromRequestWithAdapter(req, adapter)
const choice = body.tool_choice
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
...body,
enable_thinking: opts.enableThinking,
previous_response_id: opts.previousResponseId,
conversation: opts.conversation,
// Model Studio expresses named selection through allowed_tools.
tool_choice:
typeof choice === "object" && choice.type === "function"
? { type: "allowed_tools" as const, mode: "required" as const, tools: [choice] }
: choice,
})
}),
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (req) => OpenResponses.initial(req, adapter),
step: (state, input) =>
Effect.gen(function* () {
const event = OpenResponses.normalize(state, input)
if (event.type !== "response.output_item.done" || !event.item) return yield* OpenResponses.step(state, event)
if (event.item.type === "web_extractor_call") {
const item = yield* Schema.decodeUnknownEffect(WebExtractorItem)(event.item).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
adapter.id,
"Alibaba returned an invalid web extraction item",
ProviderShared.encodeJson(event),
cause,
),
),
)
return yield* ResponsesHostedTools.onDone(state, item, {
web_extractor_call: { name: "web_extractor", input: () => ({ urls: item.urls, goal: item.goal }) },
})
}
if (ResponsesHostedTools.isItem(event.item, tools))
return yield* ResponsesHostedTools.onDone(state, event.item, tools)
return yield* OpenResponses.step(state, event)
}),
terminal: OpenResponses.terminal,
},
})
export * as AlibabaResponses from "./alibaba-responses.js"
+107 -43
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
@@ -50,15 +50,24 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
@@ -301,20 +310,27 @@ const AnthropicToolChoice = Schema.Union([
}),
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(Schema.String),
})
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
const AnthropicJsonOutputFormat = Schema.Struct({
@@ -455,7 +471,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly compactions: Readonly<Record<number, string | null>>
readonly routeID: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
@@ -563,14 +579,14 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
part: ToolResultPart,
routeID: string,
providerMetadataKey: string,
) {
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.[routeID]?.["result"] ?? part.result.value
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
@@ -857,7 +873,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
breakpoints: Cache.Breakpoints,
) {
const messages: AnthropicMessage[] = []
const routeID = request.model.route.id
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
@@ -911,8 +927,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, routeID)
const redactedData = redactedDataFromMetadata(part.providerMetadata, routeID)
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
@@ -941,7 +957,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "tool-result" && part.providerExecuted) {
content.push(yield* lowerServerToolResult(part, routeID))
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
continue
}
return yield* invalid(
@@ -1025,8 +1041,9 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: yield* resolveThinking(input?.thinking),
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
output_config,
service_tier,
@@ -1037,15 +1054,41 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
}
})
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
if (!version) return false
const major = Number(version.major)
const minor = Number(version.minor ?? 0)
return major > 5 || (major === 5 && minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
if (thinking?.type === "disabled") return thinking
if (!supportsThinkingBlockBinding(model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
block_binding: {
prefix_mismatch_behavior: "drop_block",
...thinking?.block_binding,
},
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "enabled") return undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
@@ -1054,7 +1097,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
@@ -1148,7 +1191,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are included in `output_tokens`; newer responses also
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined, routeID: string): Usage | undefined => {
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const iterations = usage.iterations?.length ? usage.iterations : [usage]
const last = usage.iterations?.at(-1)
@@ -1175,7 +1218,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, routeID: string): Usage | u
cacheWriteInputTokens: cacheWrite,
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { [routeID]: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1184,7 +1227,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, routeID: string): Usage | u
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
// recomputed from the merged breakdown so the inclusive total stays
// consistent with `nonCached + cacheRead + cacheWrite`.
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, routeID: string) => {
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
if (!left) return right
if (!right) return left
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
@@ -1203,7 +1246,9 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, routeID:
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
[routeID]: mergeJsonRecords(left.providerMetadata?.[routeID], right.providerMetadata?.[routeID]) ?? {},
[providerMetadataKey]:
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
{},
},
})
}
@@ -1221,7 +1266,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: AnthropicStreamBlock, routeID: string): LLMEvent | undefined => {
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1235,7 +1280,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, routeID: string): LL
providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: providerMetadata(routeID, { blockType: block.type, result: block.content }),
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
})
}
@@ -1244,8 +1289,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mapUsage(event.message?.usage, state.routeID)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.routeID) } : state, NO_EVENTS]
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
}
const onContentBlockStart = (
@@ -1297,7 +1342,9 @@ const onContentBlockStart = (
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const metadata =
block.signature === undefined ? undefined : providerMetadata(state.routeID, { signature: block.signature })
block.signature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
return [
{
@@ -1326,14 +1373,14 @@ const onContentBlockStart = (
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
providerMetadata(state.routeID, { redactedData: block.data }),
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
),
},
events,
]
}
const result = serverToolResultEvent(block, state.routeID)
const result = serverToolResultEvent(block, state.providerMetadataKey)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
@@ -1434,7 +1481,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : providerMetadata(state.routeID, { signature }),
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
@@ -1446,7 +1493,7 @@ const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.routeID), state.routeID)
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
@@ -1455,7 +1502,7 @@ const onMessageDelta = (
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.routeID, { stopSequence })
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
@@ -1483,7 +1530,12 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
events.push(...result.events)
const closed = Object.entries(state.reasoningSignatures).reduce(
(current, [index, signature]) =>
Lifecycle.reasoningEnd(current, events, `reasoning-${index}`, providerMetadata(state.routeID, { signature })),
Lifecycle.reasoningEnd(
current,
events,
`reasoning-${index}`,
providerMetadata(state.providerMetadataKey, { signature }),
),
lifecycle,
)
const finished = Lifecycle.finish(closed, events, {
@@ -1618,7 +1670,7 @@ export const protocol = Protocol.make({
initial: (request) => ({
provider: request.model.provider,
compactions: {},
routeID: request.model.route.id,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
@@ -1627,24 +1679,21 @@ export const protocol = Protocol.make({
},
})
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
export const transport = <
Body extends Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">,
>() => {
const http = HttpTransport.httpJson<Body, string>({ framing })
return {
...http,
prepare: (input: Parameters<typeof http.prepare>[0]) => {
if (
!input.body.context_management?.edits.length &&
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
return http.prepare(input)
const requiredBetas = requiredBetaHeaders(input.body)
if (requiredBetas.length === 0) return http.prepare(input)
const headers = Headers.fromInput(input.request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
)
betas.add("compact-2026-01-12")
const existingBetas = (headers["anthropic-beta"] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const betas = new Set([...existingBetas, ...requiredBetas])
return http.prepare({
...input,
request: LLMRequest.update(input.request, {
@@ -1658,9 +1707,24 @@ export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" |
}
}
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
const betas: string[] = []
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
const replaysCompaction = body.messages.some((message) =>
message.content.some((block) => block.type === "compaction"),
)
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
betas.push("thinking-binding-controls-2026-08-01")
return betas
}
export const route = Route.make({
id: ADAPTER,
provider: "anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path((input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH), {
baseURL: DEFAULT_BASE_URL,
+17 -16
View File
@@ -260,14 +260,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const reasoningSignature = (part: ReasoningPart, routeID: string) => {
const metadata = part.providerMetadata?.[routeID]
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (part.encrypted !== undefined) return part.encrypted
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
}
const reasoningRedactedData = (part: ReasoningPart, routeID: string) => {
const metadata = part.providerMetadata?.[routeID]
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
}
@@ -324,7 +324,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const routeID = request.model.route.id
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
if (message.role === "system") {
@@ -372,8 +372,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part, routeID)
const redactedData = reasoningRedactedData(part, routeID)
const signature = reasoningSignature(part, providerMetadataKey)
const redactedData = reasoningRedactedData(part, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
@@ -485,7 +485,7 @@ const mapFinishReason = (reason: string): FinishReason => {
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined, routeID: string): Usage | undefined => {
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
@@ -499,12 +499,12 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, routeID: string): Usage
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { [routeID]: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
interface ParserState {
readonly routeID: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
@@ -587,8 +587,8 @@ const step = (state: ParserState, event: BedrockEvent) =>
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.routeID, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.routeID, { redactedData })
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
@@ -640,10 +640,10 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
const metadata = (() => {
const signature = state.reasoningSignatures[index]
if (signature) return providerMetadata(state.routeID, { signature })
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
const redactedContent = state.reasoningRedactedContent[index]
if (redactedContent)
return providerMetadata(state.routeID, {
return providerMetadata(state.providerMetadataKey, {
redactedData: encodeRedactedContent(redactedContent),
})
})()
@@ -697,7 +697,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.routeID) ?? state.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
@@ -757,7 +757,7 @@ export const protocol = Protocol.make({
stream: {
event: BedrockEvent,
initial: (request) => ({
routeID: request.model.route.id,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishReason: undefined,
usage: undefined,
@@ -774,6 +774,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
provider: "bedrock",
providerMetadataKey: "bedrock",
protocol,
// Bedrock's URL embeds the region in the route endpoint host and the
// validated modelId in the path. We read the validated body so the URL
+24 -15
View File
@@ -1,5 +1,5 @@
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
@@ -232,7 +232,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly routeID: string
readonly providerMetadataKey: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -309,7 +309,7 @@ const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const metadataKey = request.model.route.id
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
@@ -583,7 +583,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
state.reasoningId,
state.reasoningSignature === undefined
? undefined
: providerMetadata(state.routeID, { thoughtSignature: state.reasoningSignature }),
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textId !== undefined)
lifecycle = Lifecycle.textEnd(
@@ -592,7 +592,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
state.textId,
state.textSignature === undefined
? undefined
: providerMetadata(state.routeID, { thoughtSignature: state.textSignature }),
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -604,7 +604,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
providerMetadata:
state.promptFeedback === undefined
? undefined
: providerMetadata(state.routeID, { promptFeedback: state.promptFeedback }),
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
})
return events
}
@@ -634,7 +634,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata, state.routeID) ?? state.usage) : state.usage,
usage: event.usageMetadata
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
: state.usage,
}
const candidate = event.candidates?.[0]
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
@@ -690,7 +692,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
textId,
textSignature ? providerMetadata(state.routeID, { thoughtSignature: textSignature }) : undefined,
textSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textId = undefined
textSignature = undefined
@@ -704,7 +708,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
reasoningId,
part.text,
signature ? providerMetadata(state.routeID, { thoughtSignature: signature }) : undefined,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
)
continue
}
@@ -713,7 +717,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
reasoningId,
reasoningSignature ? providerMetadata(state.routeID, { thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
@@ -727,7 +733,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
textId,
part.text,
textSignature ? providerMetadata(state.routeID, { thoughtSignature: textSignature }) : undefined,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue
@@ -748,7 +754,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
reasoningId,
reasoningSignature ? providerMetadata(state.routeID, { thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
@@ -758,7 +766,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
textId,
textSignature ? providerMetadata(state.routeID, { thoughtSignature: textSignature }) : undefined,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textId = undefined
textSignature = undefined
@@ -770,7 +778,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? providerMetadata(state.routeID, { thoughtSignature: part.thoughtSignature })
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
@@ -813,7 +821,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
routeID: request.model.route.id,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
nextReasoningId: 0,
@@ -827,6 +835,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: ADAPTER,
provider: "google",
providerMetadataKey: "google",
protocol,
// Gemini's path embeds the model id and pins SSE framing at the URL level.
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
+133
View File
@@ -0,0 +1,133 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
import { Auth } from "../route/auth.js"
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import { JsonObject, ProviderShared, optionalNull } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
type OpenString<Known extends string> = Known | (string & {})
export type ImageOptions = {
readonly n?: number
/** Aspect ratio hint, not an exact output resolution. */
readonly size?: string
readonly outputFormat?: OpenString<"webp" | "png" | "jpeg">
readonly responseFormat?: OpenString<"b64_json" | "url">
readonly reasoningStrength?: OpenString<"low" | "high">
readonly toolEnablement?: {
readonly enable_image_search?: boolean
readonly enable_web_search?: boolean
readonly enable_shell?: boolean
}
readonly [key: string]: unknown
}
const Body = Schema.StructWithRest(
Schema.Struct({
model: Schema.String,
prompt: Schema.String,
images: Schema.optional(Schema.Array(JsonObject)),
n: Schema.optional(Schema.Number),
size: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.String),
response_format: Schema.optional(Schema.String),
reasoning_strength: Schema.optional(Schema.String),
tool_enablement: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
}),
[JsonObject],
)
const Response = Schema.Struct({
data: Schema.Array(Schema.Struct({ b64_json: optionalNull(Schema.String), url: optionalNull(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),
}),
),
})
export const model = (input: {
readonly id: string
readonly auth: Auth.Definition
readonly baseURL: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions
}) => {
const route: ImageRoute<ImageOptions> = {
id: "meta-images",
generate: Effect.fn("MetaImages.generate")(function* (request: ImageRequestFor<ImageOptions>, execute) {
const http = mergeHttpOptions(request.model.http, request.http)
const images = yield* Effect.forEach(request.images ?? [], (image) => {
if (image.type === "bytes") return Effect.succeed({ image_url: ImageInputs.dataUrl(image) })
if (image.type === "url") return Effect.succeed({ image_url: image.url })
return ImageInputs.invalid("Meta Images accepts image bytes and URLs")
})
const { outputFormat, responseFormat, reasoningStrength, toolEnablement, ...native } = request.options ?? {}
const payload = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
model: request.model.id,
prompt: request.prompt,
images: images.length === 0 ? undefined : images,
output_format: outputFormat,
response_format: responseFormat,
reasoning_strength: reasoningStrength,
tool_enablement: toolEnablement,
},
native,
http?.body,
),
)
const body = ProviderShared.encodeJson(payload)
const url = new URL(`${input.baseURL.replace(/\/$/, "")}/images/${images.length === 0 ? "generations" : "edits"}`)
Object.entries(http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
const headers = yield* Auth.toEffect(input.auth)({
request,
method: "POST",
url: url.toString(),
body,
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
})
const response = yield* execute(
HttpClientRequest.post(url.toString()).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(body, "application/json"),
),
)
const output = yield* ProviderShared.imageResponse("meta-images", "Meta Images", response)
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(output.body).pipe(
Effect.mapError((cause) => output.invalid("Meta Images returned an invalid response", cause)),
)
const format = decoded.output_format ?? payload.output_format ?? "webp"
const generated = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError((cause) => output.invalid(`Meta Images result ${index} contains invalid base64`, cause)),
Effect.map((data) => new GeneratedImage({ mediaType: `image/${format}`, data })),
)
if (item.url) return Effect.succeed(new GeneratedImage({ mediaType: `image/${format}`, data: item.url }))
return output.invalid(`Meta Images result ${index} has neither image data nor a URL`)
})
if (generated.length === 0) return yield* output.invalid("Meta Images returned no images")
return new ImageResponse({
images: generated,
usage:
decoded.usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
outputTokens: decoded.usage.output_tokens,
totalTokens: decoded.usage.total_tokens,
providerMetadata: { meta: decoded.usage },
}),
providerMetadata: { meta: { outputFormat: format } },
})
}),
}
return ImageModel.make<ImageOptions>({ id: input.id, provider: "meta", route, http: input.http })
}
export * as MetaImages from "./meta-images.js"
@@ -0,0 +1,52 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { MetaResponses } from "./meta-responses.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
const WebSearch = Schema.Struct({
type: Schema.Literal("web_search"),
name: Schema.Literal("web_search"),
user_location: MetaResponses.WebSearch.fields.user_location,
})
const Body = Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
tools: optionalArray(
Schema.Union([
Schema.Struct({ name: Schema.String, description: Schema.String, input_schema: JsonObject }),
WebSearch,
]),
),
})
const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LLMRequest) {
const projected = ProviderShared.flattenToolRequest(request)
const body = yield* AnthropicMessages.protocol.body.from(projected.request)
return {
...body,
tools:
body.tools === undefined
? undefined
: yield* Effect.forEach(body.tools, (tool, index) =>
Effect.gen(function* () {
const native = projected.tools[index]?.native
if (native === undefined) return tool
const search = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MetaResponses.WebSearch))(
native.meta,
)
if (search.search_context_size !== undefined)
return yield* ProviderShared.invalidRequest("Meta Messages does not support searchContextSize")
return { type: "web_search" as const, name: "web_search" as const, user_location: search.user_location }
}),
),
}
})
export const protocol = Protocol.make({
id: "meta-messages",
body: { schema: Body, from: fromRequest },
stream: AnthropicMessages.protocol.stream,
})
export * as MetaMessages from "./meta-messages.js"
+238
View File
@@ -0,0 +1,238 @@
import { Effect, Encoding, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { MetaImage } from "./utils/meta-image.js"
const ADAPTER = "meta-responses"
const NAME = "Meta Responses"
export const WebSearch = Schema.Struct({
type: Schema.Literal("web_search"),
search_context_size: Schema.optional(Schema.String),
user_location: Schema.optional(
Schema.Struct({
type: Schema.Literal("approximate"),
city: Schema.optional(Schema.String),
region: Schema.optional(Schema.String),
country: Schema.optional(Schema.String),
timezone: Schema.optional(Schema.String),
}),
),
})
export const ImageGeneration = Schema.Struct({
type: Schema.Literal("image_generation"),
size: Schema.optional(Schema.String),
output_format: Schema.optional(Schema.String),
reasoning_strength: Schema.optional(Schema.String),
enable_image_search: Schema.optional(Schema.Boolean),
enable_web_search: Schema.optional(Schema.Boolean),
enable_shell: Schema.optional(Schema.Boolean),
})
const NativeTool = Schema.Union([WebSearch, ImageGeneration])
const ImageItem = Schema.Struct({
type: Schema.Literal("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.String),
error: Schema.optional(Schema.Unknown),
})
const Body = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, ImageItem])),
tools: optionalArray(Schema.Union([OpenResponses.Tool, NativeTool])),
stream: Schema.Literal(true),
})
const MessageAnnotations = Schema.Struct({
content: Schema.Array(Schema.Struct({ annotations: optionalArray(JsonObject) })),
})
interface ParserState extends OpenResponses.ParserState {
readonly completedItems: ReadonlySet<string>
}
const adapter = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(ImageItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: LLMRequest) {
const key = request.model.route.providerMetadataKey ?? String(request.model.provider)
const projected = ProviderShared.flattenToolRequest(
LLMRequest.update(request, {
messages: request.messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (
part.type !== "tool-result" ||
!part.providerExecuted ||
part.name !== "image_generation" ||
part.result.type !== "content" ||
part.providerMetadata?.[key]?.itemId !== part.id
)
return part
// Meta's signed image ID carries edit state; replay the handle, not the image bytes as a user message.
return ToolResultPart.make({
...part,
result: {
type: "json",
value: { type: "image_generation_call", id: part.id, status: "completed", result: null },
},
})
}),
}),
),
}),
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
...(yield* OpenResponses.lowerConversation(projected.request, adapter)),
...OpenResponses.lowerGeneration(request),
tools:
projected.tools.length === 0
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
Effect.gen(function* () {
if (tool.native === undefined)
return yield* OpenResponses.lowerTool(
NAME,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model.compatibility?.toolSchema),
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
}),
),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* OpenResponses.lowerToolChoice(NAME, request.toolChoice) : undefined),
})
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
image_generation_call: {
name: "image_generation",
input: () => ({}),
result: Effect.fn("MetaResponses.imageResult")(function* (raw: ResponsesHostedTools.Item) {
const item = yield* Schema.decodeUnknownEffect(ImageItem)(raw).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
"Meta returned an invalid image item",
ProviderShared.encodeJson(raw),
cause,
),
),
)
if (item.error !== undefined && item.error !== null) return { type: "error" as const, value: item.error }
if (!item.result)
return yield* ProviderShared.eventError(
ADAPTER,
"Meta returned an image without data",
ProviderShared.encodeJson(raw),
)
const data = yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
"Meta returned invalid image base64",
ProviderShared.encodeJson(raw),
cause,
),
),
)
const mime = MetaImage.mediaType(data, item.output_format)
return {
type: "content" as const,
value: [{ type: "file" as const, uri: `data:${mime};base64,${item.result}`, mime }],
}
}),
},
} satisfies ResponsesHostedTools.Definitions
const onEvent = Effect.fn("MetaResponses.onEvent")(function* (
state: OpenResponses.ParserState,
input: OpenResponses.Event,
) {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return yield* ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
const result = yield* OpenResponses.step(state, event)
if (event.type !== "response.output_item.done" || event.item?.type !== "message") return result
const message = yield* Schema.decodeUnknownEffect(MessageAnnotations)(event.item).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
"Meta returned invalid message annotations",
ProviderShared.encodeJson(event),
cause,
),
),
)
const annotations = message.content.flatMap((part) => part.annotations ?? [])
if (annotations.length === 0) return result
return [
result[0],
result[1].map((item) =>
LLMEvent.is.textEnd(item)
? LLMEvent.textEnd({
...item,
providerMetadata: {
...item.providerMetadata,
[state.providerMetadataKey]: { ...item.providerMetadata?.[state.providerMetadataKey], annotations },
},
})
: item,
),
] satisfies OpenResponses.StepResult
})
const step = Effect.fn("MetaResponses.step")(function* (state: ParserState, input: OpenResponses.Event) {
const completedItems = new Set(state.completedItems)
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && completedItems.has(event.item.id))
return [state, []] as const
const events: LLMEvent[] = []
let current: OpenResponses.ParserState = state
// Muse Image delivers its image and optional summary only in response.completed.
// Recover terminal-only items in order, without duplicating Spark's streamed items.
if (event.type === "response.completed") {
for (const [index, item] of (event.response?.output ?? []).entries()) {
const done = OpenResponses.normalize(current, { type: "response.output_item.done", item, output_index: index })
// Spark changes reasoning IDs in the terminal snapshot; output indices still identify the streamed items.
if (!done.item || completedItems.has(done.item.id) || completedItems.has(state.outputItems[index] ?? "")) continue
const result = yield* onEvent(current, done)
current = result[0]
events.push(...result[1])
completedItems.add(done.item.id)
}
}
const result = yield* onEvent(current, event)
if (event.type === "response.output_item.done" && event.item) completedItems.add(event.item.id)
return [{ ...result[0], completedItems }, [...events, ...result[1]]] as const
})
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: Body, from: fromRequest },
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request): ParserState => ({ ...OpenResponses.initial(request, adapter), completedItems: new Set() }),
step,
terminal: OpenResponses.terminal,
},
})
export const httpTransport = HttpTransport.sseJson.with<Schema.Schema.Type<typeof Body>>()
export * as MetaResponses from "./meta-responses.js"
+13 -30
View File
@@ -287,10 +287,9 @@ const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
routeID: string,
) {
const structured = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.[routeID]?.thinking),
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const content: Array<Schema.Schema.Type<typeof MistralTextContent> | MistralThinkingContent> = []
const text: string[] = []
@@ -302,7 +301,7 @@ const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
continue
}
if (part.type === "reasoning") {
const native = part.providerMetadata?.[routeID]?.thinking
const native = part.providerMetadata?.mistral?.thinking
if (structured && isMistralThinkingContent(native)) content.push(native)
else if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
@@ -379,22 +378,13 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
if (message.role === "assistant") {
const hasToolCalls = message.content.some((part) => part.type === "tool-call")
const hasNativeThinking = message.content.some(
(part) =>
part.type === "reasoning" &&
isMistralThinkingContent(part.providerMetadata?.[request.model.route.id]?.thinking),
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const text = message.content
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
.join("")
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
messages.push(
yield* lowerAssistant(
message,
normalizeID,
!hasToolCalls && message === request.messages.at(-1),
request.model.route.id,
),
)
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
continue
}
messages.push(...(yield* lowerToolResults(message, normalizeID)))
@@ -465,7 +455,6 @@ interface ActiveContent {
}
export interface ParserState {
readonly routeID: string
readonly tools: ToolStream.State<ToolKey>
readonly pendingTools: Partial<Record<ToolKey, PendingTool>>
readonly toolIDs: ReadonlyMap<string, string>
@@ -480,7 +469,7 @@ export interface ParserState {
readonly finishReason?: FinishReasonDetails
}
const mapUsage = (usage: MistralEvent["usage"], routeID: string): Usage | undefined => {
const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const reported =
@@ -496,7 +485,7 @@ const mapUsage = (usage: MistralEvent["usage"], routeID: string): Usage | undefi
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
cacheReadInputTokens: cached,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { [routeID]: usage },
providerMetadata: { mistral: usage },
})
}
@@ -528,7 +517,7 @@ const thinkingUnits = (value: unknown): ReadonlyArray<MistralThinkingUnit> => {
const thinkingText = (thinking: ReadonlyArray<MistralThinkingUnit>) =>
thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("")
const thinkingMetadata = (thinking: MistralThinkingContent, routeID: string) => ({ [routeID]: { thinking } })
const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } })
const closeActive = (state: ParserState, events: LLMEvent[]) => {
if (!state.active) return state
@@ -539,7 +528,7 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }, state.routeID),
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
)
return { ...state, lifecycle, active: undefined }
@@ -572,14 +561,8 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(
current.lifecycle,
events,
active.id,
text,
thinkingMetadata(thinking, state.routeID),
)
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking, state.routeID)),
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
@@ -700,7 +683,7 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
})
}
const events: LLMEvent[] = []
const usage = mapUsage(event.usage, state.routeID) ?? state.usage
const usage = mapUsage(event.usage) ?? state.usage
if (state.finishReason) {
if (hasLateContent(event))
return yield* ProviderShared.eventError(
@@ -778,8 +761,7 @@ export const protocol = Protocol.make({
body: { schema: MistralBody, from: fromRequest },
stream: {
event: MistralStreamEvent,
initial: (request): ParserState => ({
routeID: request.model.route.id,
initial: (): ParserState => ({
tools: ToolStream.empty<ToolKey>(),
pendingTools: {},
toolIDs: new Map(),
@@ -801,6 +783,7 @@ export const httpTransport = HttpTransport.sseJson.with<MistralBody>().with({ fr
export const route = Route.make({
id: ADAPTER,
provider: "mistral",
providerMetadataKey: "mistral",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
+34 -27
View File
@@ -1,5 +1,5 @@
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
@@ -408,6 +408,9 @@ export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly nativeTool?: (
native: NonNullable<ToolDefinition["native"]>,
) => Effect.Effect<{ readonly type: string }, AIError>
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: ProviderShared.NormalizedMedia
@@ -423,7 +426,7 @@ export interface ParserState {
readonly completedCompactions: ReadonlySet<string>
readonly id: string
readonly name: string
readonly routeID: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
@@ -476,15 +479,15 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
// Server-issued item ids need a nonempty prefix and suffix, but the prefix is
// provider-defined and does not necessarily identify the item's semantic type.
const itemID = (providerMetadata: ProviderMetadata | undefined, routeID: string) => {
const metadata = providerMetadata?.[routeID]
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
const separator = metadata.itemId.indexOf("_")
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const lowerToolCall = (part: ToolCallPart, routeID: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, routeID)
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id === undefined ? {} : { id }),
@@ -495,10 +498,10 @@ const lowerToolCall = (part: ToolCallPart, routeID: string): OpenResponsesInputI
}
}
const lowerReasoning = (part: ReasoningPart, routeID: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[routeID]
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, routeID)
const id = itemID(part.providerMetadata, providerMetadataKey)
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
@@ -521,7 +524,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[request.model.route.id]?.detail,
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
@@ -608,12 +611,12 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const routeID = request.model.route.id
const providerMetadataKey = metadataKey(request.model)
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[routeID])
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
input.push({
role: "developer",
@@ -638,8 +641,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const partMetadata = part.providerMetadata?.[routeID]
const id = itemID(part.providerMetadata, routeID) ?? metadata?.itemId
const partMetadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
const partPhase = messagePhase(partMetadata?.phase)
const phase = partPhase === undefined ? metadata?.phase : partPhase
const group = groups.at(-1)
@@ -675,7 +678,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, routeID)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
@@ -691,12 +694,12 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, routeID))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, routeID)
const id = itemID(part.providerMetadata, providerMetadataKey)
const hosted =
part.result.type !== "json"
? undefined
@@ -819,11 +822,13 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
projected.tools.length === 0
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
lowerTool(
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
tool.native !== undefined && adapter.nativeTool
? adapter.nativeTool(tool.native)
: lowerTool(
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
@@ -844,7 +849,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, routeID: string) => {
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
@@ -858,7 +863,7 @@ export const mapUsage = (usage: OpenResponsesUsage | null | undefined, routeID:
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { [routeID]: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -874,8 +879,10 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
return hasFunctionCall ? "tool-calls" : "unknown"
}
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.routeID]: metadata,
[state.providerMetadataKey]: metadata,
})
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -1347,7 +1354,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
normalized: mapFinishReason(event, current.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.routeID),
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(current, {
@@ -1484,7 +1491,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
completedCompactions: new Set<string>(),
id: adapter.id,
name: adapter.name,
routeID: request.model.route.id,
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
+26 -24
View File
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
@@ -257,7 +257,7 @@ interface PendingToolDelta {
}
export interface ParserState {
readonly routeID: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
@@ -329,17 +329,18 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart, routeID: string) => {
const field = part.providerMetadata?.[routeID]?.reasoningField
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, routeID: string) => {
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.[routeID]?.reasoningDetails
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.[routeID]?.reasoningDetails))) return observed
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
@@ -371,7 +372,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions & { readonly routeID: string },
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -393,13 +394,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.routeID)
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
const observedField = reasoning
.map((part) => reasoningField(part, options.routeID))
.map((part) => reasoningField(part, options.providerMetadataKey))
.find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) =>
Array.isArray(part.providerMetadata?.[options.routeID]?.reasoningDetails),
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
)
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
@@ -468,7 +469,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions & { readonly routeID: string },
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
@@ -504,7 +505,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
routeID: request.model.route.id,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
toolCallID: (id: string) => {
if (mistral)
return id
@@ -832,7 +833,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"], routeID: string): Usage | undefined => {
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
@@ -851,7 +852,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], routeID: string): Usage | und
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { [routeID]: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -926,11 +927,11 @@ const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (
routeID: string,
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
) => ({
[routeID]: {
[providerMetadataKey]: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
@@ -954,8 +955,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage =
mapUsage(event.usage, state.routeID) ??
(choiceUsage ? mapUsage(choiceUsage, state.routeID) : undefined) ??
mapUsage(event.usage, state.providerMetadataKey) ??
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
state.usage
const rawFinishReason = choice?.finish_reason
const finishReason = rawFinishReason
@@ -994,7 +995,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
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(state.routeID, reasoningField)
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
@@ -1083,7 +1084,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
routeID: state.routeID,
providerMetadataKey: state.providerMetadataKey,
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
@@ -1127,7 +1128,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
const metadata = reasoningMetadata(
state.routeID,
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
)
@@ -1137,7 +1138,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
state.lifecycle,
events,
"reasoning-0",
reasoningMetadata(state.routeID, state.reasoningField),
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
)
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
@@ -1165,7 +1166,7 @@ export const protocol = Protocol.make({
stream: {
event: OpenAIChatStreamEvent,
initial: (request) => ({
routeID: request.model.route.id,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
@@ -1189,6 +1190,7 @@ export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({
export const route = Route.make({
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
@@ -15,6 +15,7 @@ export type OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInp
*/
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
@@ -13,6 +13,7 @@ export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageMod
*/
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: "openresponses",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
@@ -313,6 +313,7 @@ export const route = Route.make({
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint,
auth,
+1 -1
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -0,0 +1,11 @@
// Responses image items can omit output_format, including when PNG/JPEG was requested.
export const mediaType = (data: Uint8Array, format?: string) => {
if (format !== undefined) return `image/${format}`
if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png"
if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg"
if (new TextDecoder().decode(data.slice(0, 4)) === "RIFF" && new TextDecoder().decode(data.slice(8, 12)) === "WEBP")
return "image/webp"
return "application/octet-stream"
}
export * as MetaImage from "./meta-image.js"
@@ -93,7 +93,7 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
result = new CompactionCheckpointResponse({
checkpoint,
responseID,
usage: OpenResponses.mapUsage(event.response?.usage, request.model.route.id),
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
})
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
}),
@@ -102,6 +102,7 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
const route = Route.make({
id: source.id,
provider: source.provider,
providerMetadataKey: source.providerMetadataKey,
protocol,
endpoint: source.endpoint,
auth: source.auth,
@@ -125,7 +125,7 @@ export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =
return yield* invalid("Compaction response did not contain a checkpoint")
return new CompactionResponse({
replacement: result.output.map((item) => toMessage(item, request.model)),
usage: OpenResponses.mapUsage(result.usage, request.model.route.id),
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
})
})
@@ -135,7 +135,7 @@ function toMessage(item: (typeof Response.Type.output)[number], model: LLMReques
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
)
const key = model.route.id
const key = OpenResponses.metadataKey(model)
if (item.type === "reasoning") {
const summary = item.summary.length ? item.summary : [{ text: "" }]
return Message.assistant(
+75
View File
@@ -0,0 +1,75 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LanguageModelCompatibility, LLMRequest } from "../schema/index.js"
import { OpenAIChat } from "./openai-chat.js"
import { ProviderShared } from "./shared.js"
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
export type OptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly thinking?: {
readonly type?: "enabled" | "disabled" | (string & {})
/** False retains historical reasoning; omission preserves the endpoint's default. */
readonly clear_thinking?: boolean
}
readonly toolStream?: boolean
readonly doSample?: boolean
readonly responseFormat?: { readonly type: "text" | "json_object" | (string & {}) }
readonly requestID?: string
readonly userID?: string
}
const Options = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), clear_thinking: Schema.optional(Schema.Boolean) }),
),
toolStream: Schema.optional(Schema.Boolean),
doSample: Schema.optional(Schema.Boolean),
responseFormat: Schema.optional(Schema.Struct({ type: Schema.String })),
requestID: Schema.optional(Schema.String),
userID: Schema.optional(Schema.String),
})
const Body = Schema.Struct({
...OpenAIChat.bodyFields,
thinking: Options.fields.thinking,
do_sample: Options.fields.doSample,
response_format: Options.fields.responseFormat,
request_id: Options.fields.requestID,
user_id: Options.fields.userID,
})
const fromRequest = Effect.fn("ZAIChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const body = yield* OpenAIChat.protocol.body.from(request)
return {
...body,
thinking: options.thinking,
// Tool streaming was introduced in GLM-4.6; older models must not receive the opt-in.
tool_stream:
options.toolStream ??
(body.tools?.length && /^glm-(?:4\.[67]|5(?:[.-]|$))/i.test(request.model.id) ? true : undefined),
do_sample: options.doSample,
response_format: options.responseFormat,
request_id: options.requestID,
user_id: options.userID,
}
})
export const compatibility = {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
reasoningField: "reasoning_content",
zaiToolStream: false,
} satisfies LanguageModelCompatibility
export const protocol = Protocol.make({
id: "zai-chat",
body: { schema: Body, from: fromRequest },
stream: OpenAIChat.protocol.stream,
})
export * as ZAIChat from "./zai-chat.js"
+39
View File
@@ -0,0 +1,39 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { LLMRequest } from "../schema/index.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { ProviderShared } from "./shared.js"
import type { ZAIChat } from "./zai-chat.js"
export type OptionsInput = {
readonly effort?: ZAIChat.ReasoningEffort
readonly thinking?: { readonly type: "enabled" | "adaptive" | "disabled" | (string & {}) }
}
const Options = Schema.Struct({
effort: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
})
const Body = Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
thinking: Options.fields.thinking,
})
const fromRequest = Effect.fn("ZAIMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
// Z.AI accepts enabled thinking without Anthropic's mandatory token budget.
const body = yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(request, {
providerOptions: { ...request.providerOptions, thinking: undefined },
}),
)
return { ...body, thinking: options.thinking }
})
export const protocol = Protocol.make({
id: "zai-messages",
body: { schema: Body, from: fromRequest },
stream: AnthropicMessages.protocol.stream,
})
export * as ZAIMessages from "./zai-messages.js"
+134
View File
@@ -0,0 +1,134 @@
import type { ProviderPackage } from "../provider-package.js"
import { AlibabaChat } from "../protocols/alibaba-chat.js"
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
import { AlibabaResponses } from "../protocols/alibaba-responses.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
export type Region =
| "ap-southeast-1"
| "cn-beijing"
| "cn-hongkong"
| "us-east-1"
| "eu-central-1"
| "ap-northeast-1"
| (string & {})
export type ChatOptionsInput = AlibabaChat.OptionsInput
export type MessagesOptionsInput = AlibabaMessages.OptionsInput
export type ResponsesOptionsInput = AlibabaResponses.OptionsInput
type Location = AtLeastOne<{
readonly region: Region
/** Overrides the selected API's complete base URL, including its version prefix. */
readonly baseURL: string
}> & { readonly workspaceID?: string }
export type Config = Location &
Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = Location &
ProviderPackage.Settings & {
readonly apiKey?: string
readonly providerOptions?: Options
}
const hosts = new Map<string, string>([
["ap-southeast-1", "dashscope-intl.aliyuncs.com"],
["cn-beijing", "dashscope.aliyuncs.com"],
["cn-hongkong", "cn-hongkong.dashscope.aliyuncs.com"],
["us-east-1", "dashscope-us.aliyuncs.com"],
])
const chatRoute = Route.make({
id: "alibaba-chat",
provider: id,
providerMetadataKey: "alibaba",
protocol: AlibabaChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
})
const messagesRoute = Route.make({
id: "alibaba-messages",
provider: id,
providerMetadataKey: "alibaba",
protocol: AlibabaMessages.protocol,
endpoint: Endpoint.path("/messages"),
framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const responsesRoute = Route.make({
id: "alibaba-responses",
provider: id,
providerMetadataKey: "alibaba",
protocol: AlibabaResponses.protocol,
endpoint: Endpoint.path("/responses"),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config) => {
const { apiKey: _key, auth: _auth, region, workspaceID, baseURL, ...rest } = input
const host =
region === undefined
? undefined
: workspaceID === undefined
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
const chat = (id: string | ModelID) =>
chatRoute.with(common).model<ChatOptionsInput>({ id, compatibility: AlibabaChat.compatibility })
const messages = (id: string | ModelID) =>
messagesRoute
.with({
...opts,
endpoint: { baseURL: baseURL ?? `https://${host}/apps/anthropic/v1` },
})
.model<MessagesOptionsInput>({ id, compatibility: { requireSignature: false } })
const responses = (id: string | ModelID) => responsesRoute.with(common).model<ResponsesOptionsInput>({ id })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (id, input) =>
fromSettings(input).chat(id)
export const messagesModel: ProviderPackage.Definition<
Settings<MessagesOptionsInput>,
MessagesOptionsInput
>["model"] = (id, input) => fromSettings(input).messages(id)
export const responsesModel: ProviderPackage.Definition<
Settings<ResponsesOptionsInput>,
ResponsesOptionsInput
>["model"] = (id, input) => fromSettings(input).responses(id)
function fromSettings(input: Settings<Config["providerOptions"]>) {
const { body, ...rest } = input
return configure({ ...rest, http: body === undefined ? undefined : { body } })
}
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
export const webExtractor = () => hostedTool("web_extractor", "Extract web page content with Alibaba's hosted tool.")
export const codeInterpreter = () => hostedTool("code_interpreter", "Execute code with Alibaba's hosted interpreter.")
function hostedTool(type: "web_search" | "web_extractor" | "code_interpreter", description: string) {
return ToolDefinition.make({
name: type,
description,
inputSchema: { type: "object", properties: {} },
native: { alibaba: { type } },
})
}
export * as Alibaba from "./alibaba.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../alibaba.js"
@@ -0,0 +1,3 @@
import type { Alibaba } from "../alibaba.js"
export { messagesModel as model } from "../alibaba.js"
export type Settings = Alibaba.Settings<Alibaba.MessagesOptionsInput>
@@ -0,0 +1,3 @@
import type { Alibaba } from "../alibaba.js"
export { responsesModel as model } from "../alibaba.js"
export type Settings = Alibaba.Settings<Alibaba.ResponsesOptionsInput>
@@ -36,6 +36,7 @@ export interface Settings extends ProviderPackage.Settings {
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
@@ -46,6 +47,7 @@ const responsesRoute = Route.make({
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
providerMetadataKey: "mantle",
})
export const routes = [responsesRoute, chatRoute]
@@ -45,6 +45,7 @@ const configuredRoute = (input: Config) => {
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }),
})
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "baseten-chat",
provider: id,
providerMetadataKey: "baseten",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "cerebras-chat",
provider: id,
providerMetadataKey: "cerebras",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
@@ -53,6 +53,7 @@ const auth = (input: LanguageModelOptions) => {
export const route = Route.make({
id: "cloudflare-ai-gateway",
provider: id,
providerMetadataKey: "cloudflare-ai-gateway",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
@@ -35,6 +35,7 @@ export const baseURL = (input: WorkersAIURL) => {
export const route = Route.make({
id: "cloudflare-workers-ai",
provider: id,
providerMetadataKey: "cloudflare-workers-ai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "deepinfra-chat",
provider: id,
providerMetadataKey: "deepinfra",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "deepseek-chat",
provider: id,
providerMetadataKey: "deepseek",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "fireworks-chat",
provider: id,
providerMetadataKey: "fireworks",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
@@ -28,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = Route.make({
id: "google-vertex-chat",
provider: id,
providerMetadataKey: "vertex",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
@@ -37,6 +37,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = Route.make({
id: "google-vertex-messages",
provider: id,
providerMetadataKey: "anthropic",
protocol: Protocol.make({
id: AnthropicMessages.protocol.id,
body: {
@@ -28,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = Route.make({
id: "google-vertex-responses",
provider: id,
providerMetadataKey: "vertex",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
@@ -68,6 +68,7 @@ const protocol = {
const route = Route.make({
id: "google-vertex-gemini",
provider: id,
providerMetadataKey: "vertex",
protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
+1
View File
@@ -71,6 +71,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: "groq-chat",
provider: id,
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+5
View File
@@ -1,3 +1,4 @@
export * as Alibaba from "./alibaba.js"
export * as Anthropic from "./anthropic.js"
export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
@@ -16,7 +17,10 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
export * as GoogleVertexMessages from "./google-vertex-messages.js"
export * as GoogleVertexResponses from "./google-vertex-responses.js"
export * as Groq from "./groq.js"
export * as Meta from "./meta.js"
export * as MiniMax from "./minimax.js"
export * as Mistral from "./mistral.js"
export * as Moonshot from "./moonshot.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
@@ -24,3 +28,4 @@ export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
export * as ZAICodingPlan from "./zai-coding-plan.js"
+182
View File
@@ -0,0 +1,182 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { MetaResponses } from "../protocols/meta-responses.js"
import { MetaMessages } from "../protocols/meta-messages.js"
import { MetaImages } from "../protocols/meta-images.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export const id = ProviderID.make("meta")
const baseURL = "https://api.meta.ai/v1"
export type ProviderOptionsInput = OpenResponsesProviderOptionsInput &
Pick<AnthropicMessages.OptionsInput, "thinking" | "effort">
export type MessagesOptionsInput = Pick<
AnthropicMessages.OptionsInput,
"thinking" | "effort" | "outputConfig" | "output_config" | "serviceTier" | "service_tier" | "metadata"
> & { readonly [key: string]: unknown }
export type ImageOptions = MetaImages.ImageOptions
export interface WebSearchOptions {
readonly searchContextSize?: "low" | "medium" | "high" | (string & {})
readonly userLocation?: {
readonly city?: string
readonly region?: string
readonly country?: string
readonly timezone?: string
}
}
export const webSearch = (options: WebSearchOptions = {}) =>
ToolDefinition.make({
name: "web_search",
description: "Search the web with Meta's hosted search tool.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
meta: {
type: "web_search",
search_context_size: options.searchContextSize,
user_location:
options.userLocation === undefined ? undefined : { type: "approximate", ...options.userLocation },
},
},
})
export interface ImageGenerationOptions {
readonly size?: string
readonly outputFormat?: "webp" | "png" | "jpeg" | (string & {})
readonly reasoningStrength?: "low" | "high" | (string & {})
readonly enableImageSearch?: boolean
readonly enableWebSearch?: boolean
readonly enableShell?: boolean
}
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
ToolDefinition.make({
name: "image_generation",
description: "Generate or edit an image with Muse Image.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
native: {
meta: {
type: "image_generation",
size: options.size,
output_format: options.outputFormat,
reasoning_strength: options.reasoningStrength,
enable_image_search: options.enableImageSearch,
enable_web_search: options.enableWebSearch,
enable_shell: options.enableShell,
},
},
})
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
const responsesRoute = Route.make({
id: "meta-responses",
provider: id,
providerMetadataKey: "meta",
protocol: MetaResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
// Meta Responses does not support WebSocket upgrades; always use HTTP/SSE.
transport: MetaResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = Route.make({
id: "meta-chat",
provider: id,
providerMetadataKey: "meta",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "meta-messages",
provider: id,
providerMetadataKey: "meta",
protocol: MetaMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL }),
framing: AnthropicMessages.framing,
defaults: { providerOptions: { thinking: { type: "adaptive", display: "omitted" } } },
})
export const routes = [responsesRoute, chatRoute, messagesRoute]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const options = {
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, "META_API_KEY"),
}
const configuredResponses = responsesRoute.with(options)
const configuredChat = chatRoute.with(options)
const configuredMessages = messagesRoute.with(options)
const responses = (modelID: string | ModelID) =>
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChat.model<OpenResponsesProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
})
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false },
})
const image = (modelID: string | ModelID) =>
MetaImages.model({
id: modelID,
baseURL: endpoint ?? baseURL,
auth: options.auth,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return { id, model: responses, responses, chat, messages, image, configure }
}
export const provider = configure()
export const responses = provider.responses
export const chat = provider.chat
export const messages = provider.messages
export const image = provider.image
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => fromSettings(settings).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => fromSettings(settings).chat(modelID)
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
fromSettings(settings).messages(modelID)
function fromSettings(settings: Settings) {
return configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
})
}
export * as Meta from "./meta.js"
+2
View File
@@ -0,0 +1,2 @@
export { chatModel as model } from "../meta.js"
export type { Settings } from "../meta.js"
@@ -0,0 +1,2 @@
export { messagesModel as model } from "../meta.js"
export type { Settings } from "../meta.js"
@@ -0,0 +1,2 @@
export { model } from "../meta.js"
export type { Settings } from "../meta.js"
+144
View File
@@ -0,0 +1,144 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("minimax")
export type MessagesOptionsInput = {
/** M3 defaults to disabled; M2.x always thinks. */
readonly thinking?: { readonly type: "adaptive" | "disabled" }
readonly metadata?: AnthropicMessages.OptionsInput["metadata"]
}
export type ChatOptionsInput = {
/** M3 defaults to adaptive; M2.x always thinks. */
readonly thinking?: { readonly type: "adaptive" | "disabled" | (string & {}) }
/** Separates reasoning from text. Defaults to true. */
readonly reasoningSplit?: boolean
}
export type ResponsesOptionsInput = {
/** M3 defaults to none. Other supported values enable thinking without changing its depth. */
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | (string & {})
}
export type ProviderOptionsInput = MessagesOptionsInput | ChatOptionsInput | ResponsesOptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's base URL, including its version prefix. */
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
reasoningSplit: Schema.optional(Schema.Boolean),
})
const chatProtocol = Protocol.make({
id: "minimax-chat",
body: {
schema: Schema.Struct({
...OpenAIChat.bodyFields,
thinking: ChatOptions.fields.thinking,
reasoning_split: Schema.Boolean,
}),
from: Effect.fn("MiniMax.chatFromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ChatOptions))(
request.providerOptions ?? {},
)
return {
...(yield* OpenAIChat.protocol.body.from(request)),
thinking: options.thinking,
// MiniMax otherwise embeds <think> tags in ordinary assistant text.
reasoning_split: options.reasoningSplit ?? true,
}
}),
},
stream: OpenAIChat.protocol.stream,
})
const messagesRoute = Route.make({
id: "minimax-messages",
provider: id,
providerMetadataKey: "minimax",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.minimax.io/anthropic/v1" }),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const chatRoute = Route.make({
id: "minimax-chat",
provider: id,
providerMetadataKey: "minimax",
protocol: chatProtocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.minimax.io/v1" }),
framing: OpenAIChat.framing,
})
const responsesRoute = Route.make({
id: "minimax-responses",
provider: id,
providerMetadataKey: "minimax",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.minimax.io/v1" }),
framing: Framing.sse,
})
export const routes = [messagesRoute, chatRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, "MINIMAX_API_KEY"),
}
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({
id: modelID,
compatibility: { supportsStore: false, supportsStrictMode: false },
})
const responses = (modelID: string | ModelID) =>
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
return { id, model: messages, messages, chat, responses, configure }
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export const messages = provider.messages
export const chat = provider.chat
export const responses = provider.responses
export * as MiniMax from "./minimax.js"
+13
View File
@@ -0,0 +1,13 @@
import type { ProviderPackage } from "../../provider-package.js"
import { MiniMax } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).chat(modelID)
@@ -0,0 +1 @@
export { model, type Settings, type MessagesOptionsInput } from "../minimax.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { MiniMax } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+145
View File
@@ -0,0 +1,145 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("moonshotai")
export type ReasoningEffort = "low" | "high" | "max" | (string & {})
export type ChatOptionsInput = {
/** K3 always reasons; omitted effort uses the model's default. */
readonly reasoningEffort?: ReasoningEffort
/** K2.6 supports disabling thinking; K2.7 Code always thinks and preserves reasoning. */
readonly thinking?: {
readonly type: "enabled" | "disabled" | (string & {})
readonly keep?: "all" | (string & {}) | null
}
}
export type MessagesOptionsInput = {
readonly effort?: ReasoningEffort
readonly metadata?: AnthropicMessages.OptionsInput["metadata"]
}
export type ResponsesOptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly safetyIdentifier?: string
}
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's base URL, including its version prefix. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.String, keep: Schema.optional(Schema.NullOr(Schema.String)) }),
),
})
const chatProtocol = Protocol.make({
id: "moonshot-chat",
body: {
schema: Schema.Struct({ ...OpenAIChat.bodyFields, thinking: ChatOptions.fields.thinking }),
from: Effect.fn("Moonshot.chatFromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ChatOptions))(
request.providerOptions ?? {},
)
return { ...(yield* OpenAIChat.protocol.body.from(request)), thinking: options.thinking }
}),
},
stream: OpenAIChat.protocol.stream,
})
const chatRoute = Route.make({
id: "moonshot-chat",
provider: id,
providerMetadataKey: "moonshot",
protocol: chatProtocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.moonshot.ai/v1" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "moonshot-messages",
provider: id,
providerMetadataKey: "moonshot",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.moonshot.ai/anthropic/v1" }),
framing: AnthropicMessages.framing,
})
const responsesRoute = Route.make({
id: "moonshot-responses",
provider: id,
providerMetadataKey: "moonshot",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.moonshot.ai/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, ["MOONSHOT_API_KEY", "MOONSHOTAI_API_KEY"]),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({
id: modelID,
compatibility: {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
})
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false, toolSchema: "moonshot" },
})
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(defaults)
.model<ResponsesOptionsInput>({ id: modelID, compatibility: { toolSchema: "moonshot" } })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Moonshot from "./moonshot.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../moonshot.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+1
View File
@@ -162,6 +162,7 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
provider: id,
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -24,6 +24,7 @@ export interface Settings extends ProviderPackage.Settings {
export const route = Route.make({
id: "togetherai-chat",
provider: id,
providerMetadataKey: "togetherai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+5 -3
View File
@@ -32,12 +32,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: { endpoint: XAIResponses.compact },
id: "xai-responses",
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponsesChannel.transport({
id: "xai-responses",
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
@@ -45,8 +46,9 @@ const responsesRoute = Route.make({
})
const chatRoute = Route.make({
id: "xai-chat",
id: "openai-compatible-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
@@ -0,0 +1,92 @@
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIMessages } from "../protocols/zai-messages.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai-coding-plan")
export type ChatOptionsInput = ZAIChat.OptionsInput
export type MessagesOptionsInput = ZAIMessages.OptionsInput
export type ResponsesOptionsInput = { readonly reasoningEffort?: ZAIChat.ReasoningEffort }
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's complete base URL. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const chatRoute = Route.make({
id: "zai-coding-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/coding/paas/v4" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "zai-coding-messages",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.z.ai/api/anthropic/v1" }),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const responsesRoute = Route.make({
id: "zai-coding-responses",
provider: id,
providerMetadataKey: "zai",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.z.ai/api/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, "ZAI_API_KEY"),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const messages = (modelID: string | ModelID) =>
messagesRoute
.with(defaults)
.model<MessagesOptionsInput>({ id: modelID, compatibility: { requireSignature: false } })
const responses = (modelID: string | ModelID) =>
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../zai-coding-plan.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+50 -3
View File
@@ -1,20 +1,53 @@
import type { ProviderPackage } from "../provider-package.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIImages } from "../protocols/zai-images.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai")
export type Config = ProviderAuthOption<"optional"> & {
export type ChatOptionsInput = ZAIChat.OptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
readonly providerOptions?: ChatOptionsInput
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
const chatRoute = Route.make({
id: "zai-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/paas/v4" }),
framing: OpenAIChat.framing,
})
export const routes = [chatRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const chat = (modelID: string | ModelID) =>
chatRoute
.with({
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: auth(input),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const image = (modelID: string | ModelID) =>
ZAIImages.model({
id: modelID,
@@ -26,6 +59,8 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
chat,
image,
configure,
}
@@ -33,3 +68,15 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+1
View File
@@ -0,0 +1 @@
export { model, type Settings } from "../zai.js"
+17 -4
View File
@@ -42,9 +42,10 @@ export interface Route<
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
> {
readonly compact: Compact
/** Route identity and namespace for metadata emitted and replayed by this route. */
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
readonly providerMetadataKey?: string
readonly protocol: ProtocolID
readonly endpoint: Endpoint.Definition<Body>
readonly auth: Auth.Definition
@@ -112,6 +113,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly compact?: CompactionOperations
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
readonly auth?: Auth.Definition
readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
@@ -274,10 +276,12 @@ const resolveRequestOptions = (request: LLMRequest) => {
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactionOperations
/** Route identity, also used as the namespace for emitted and replayed ProviderMetadata. */
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
readonly providerMetadataKey?: string
/** Semantic API contract — owns body construction, body schema, and parsing. */
readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */
@@ -294,10 +298,12 @@ export interface MakeInput<Body, Frame, Event, State> {
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactionOperations
/** Route identity, also used as the namespace for emitted and replayed ProviderMetadata. */
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
readonly providerMetadataKey?: string
/** Semantic API contract — owns body construction, body schema, and parsing. */
readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */
@@ -370,6 +376,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
compact: routeInput.compact,
id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
@@ -378,12 +385,17 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, auth, transport, endpoint, ...defaults } = patch
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
compact: "compact" in patch ? compact : routeInput.compact,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
providerMetadataKey ??
(provider !== undefined && String(provider) !== String(routeInput.provider)
? String(provider)
: routeInput.providerMetadataKey),
auth: auth ?? routeInput.auth,
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
@@ -524,6 +536,7 @@ export function make<Body, Prepared, Frame, Event, State>(
compact: input.compact,
id: input.id,
provider: input.provider,
providerMetadataKey: input.providerMetadataKey,
protocol,
endpoint: input.endpoint,
auth: input.auth,
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { LLM } from "@opencode-ai/schema/llm"
import { LLM } from "@opencode/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
+1 -2
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import {
CacheHint,
CachePolicy,
@@ -15,7 +15,6 @@ import { ProviderID } from "./ids.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
/** Provider-specific data; LLM routes emit and replay it under their route ID. */
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "LLM.ProviderMetadata",
})
+2
View File
@@ -163,6 +163,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Tool } from "@opencode/schema/tool"
import type {
ToolCallPart,
ToolDefinition as ToolDefinitionClass,
+2 -2
View File
@@ -66,14 +66,14 @@ export function continuationRequest(input: {
firstAssistant.push({
type: "reasoning",
text: "I inspected the previous turn.",
providerMetadata: { [input.model.route.id]: { signature: "sig_continuation_1" } },
providerMetadata: { anthropic: { signature: "sig_continuation_1" } },
})
if (features.has("encrypted-reasoning"))
firstAssistant.push({
type: "reasoning",
text: "I inspected the previous turn.",
providerMetadata: {
[input.model.route.id]: {
openai: {
itemId: "rs_continuation_1",
reasoningEncryptedContent: "encrypted-continuation-state",
},
+10 -10
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode/ai"
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
Baseten,
CloudflareAIGateway,
@@ -12,7 +12,7 @@ import {
OpenAICompatible,
OpenRouter,
XAI,
} from "@opencode-ai/ai/providers"
} from "@opencode/ai/providers"
import {
OpenAIChat,
OpenAICompatibleChat,
@@ -20,9 +20,9 @@ import {
OpenAIResponses,
OpenResponses,
OpenResponsesChannel,
} from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing"
} from "@opencode/ai/protocols"
import * as AnthropicMessages from "@opencode/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode/ai/testing"
describe("public exports", () => {
test("root exposes app-facing runtime APIs", () => {
@@ -46,7 +46,7 @@ describe("public exports", () => {
})
test("provider barrels expose user-facing facades", async () => {
const { OpenAICompatibleResponses } = await import("@opencode-ai/ai/providers")
const { OpenAICompatibleResponses } = await import("@opencode/ai/providers")
expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
@@ -69,8 +69,8 @@ describe("public exports", () => {
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
})
test("protocol barrels expose supported low-level routes", () => {
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:alibaba-chat",
"provider:alibaba",
"protocol:alibaba-chat",
"region:ap-southeast-1",
"thinking",
"usage"
],
"name": "alibaba-chat/qwen-3-7-plus-streams-thinking-disabled",
"recordedAt": "2026-09-08T03:10:42.782Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"enable_thinking\":false}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=utf-8"
},
"body": "data: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"7887\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837041,\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"model\":\"qwen3.7-plus\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":37}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:alibaba-chat",
"provider:alibaba",
"protocol:alibaba-chat",
"region:ap-southeast-1",
"tool",
"tool-choice"
],
"name": "alibaba-chat/qwen-3-8-max-obeys-named-tool-choice",
"recordedAt": "2026-09-08T03:10:56.576Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"Find the current weather in Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":4096}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=utf-8"
},
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4eb823cb28d141c8befbb331\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\\\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\"},\"index\":0,\"id\":null,\"type\":\"function\"}],\"content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837055,\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":19,\"prompt_tokens\":288,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":288},\"total_tokens\":307}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:alibaba-chat",
"provider:alibaba",
"protocol:alibaba-chat",
"region:ap-southeast-1",
"structured-output"
],
"name": "alibaba-chat/qwen-3-8-max-returns-a-json-object",
"recordedAt": "2026-09-08T03:11:29.462Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":1024,\"response_format\":{\"type\":\"json_object\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=utf-8"
},
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"{\\\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"city\\\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\" \\\"Paris\\\"}\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837088,\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":6,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":38}}\n\ndata: [DONE]\n\n"
}
}
]
}

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