mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
562 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5aeea550c | |||
| 930b1dde3c | |||
| 93e8b75cca | |||
| 8df03aa1bc | |||
| 8ce850e142 | |||
| 3f30203b72 | |||
| dd014120cd | |||
| bf7f0cb521 | |||
| adbd22833c | |||
| 634b9b21dd | |||
| 5d729521d3 | |||
| c391983ae0 | |||
| f158abd694 | |||
| 0e3da4b4f1 | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f | |||
| 85ea15e56d | |||
| 6a543791b6 | |||
| 47c6840752 | |||
| 14e4c3dfd7 | |||
| ee6577936d | |||
| 8285a18f18 | |||
| cfcd8de62a | |||
| c2db18c59b | |||
| 70393a1d18 | |||
| eab59ea021 | |||
| 4569659b03 | |||
| b5afaed053 | |||
| 97f5fb3399 | |||
| 18843682ad | |||
| 9eb9c1db7d | |||
| 8846336e11 | |||
| f6b5f6b6b3 | |||
| 02df1e9737 | |||
| 5b84e39f6d | |||
| 6d3f84b4ff | |||
| 255e9bbf06 | |||
| 25b6a179e2 | |||
| 80874c241f | |||
| f99469bdf8 | |||
| fe0eb4ea8f | |||
| a484f87680 | |||
| 3b6ab392ac | |||
| 6c567dc745 | |||
| 92567700f6 | |||
| 90c84639e3 | |||
| 56c6add5c3 | |||
| 2cfe8883ea | |||
| 102086c50f | |||
| 06e26d89ad | |||
| 84dd56ed34 | |||
| f92e490bb6 | |||
| 0481dba88e | |||
| dd0e41c633 | |||
| b69e51d835 | |||
| e60e8d9387 | |||
| 3bfce3fd2d | |||
| b498a5c6c4 | |||
| db9e942398 | |||
| 0e26116f68 | |||
| 3468aa0140 | |||
| 7757f712a2 | |||
| 7129ed6e62 | |||
| 6c37842520 | |||
| dc3c996892 | |||
| 7fd12c560c | |||
| 7aaf4e7750 | |||
| 1311d909a7 | |||
| d07d9ae0da | |||
| 1d18459cdc | |||
| db45026c6c | |||
| 77df98db51 | |||
| ff2b184af1 | |||
| 671e164f8d | |||
| 5b4ebf3e9d | |||
| 98229d466d | |||
| 5c7e2b5042 | |||
| 0a6a5d3e80 | |||
| eb95bd27fe | |||
| 865f512a44 | |||
| a460f02f67 | |||
| 146fdb9de1 | |||
| b866900417 | |||
| 8e3e94aa26 | |||
| cc1289048e | |||
| 7814568ba0 | |||
| 16b247f756 | |||
| 9abd9594de | |||
| 22d2012f75 | |||
| cc883058df | |||
| 8ebc4c6f85 | |||
| b0ed9990b3 | |||
| e1c04dcce6 | |||
| 188c642d8c | |||
| 9d55b223bb | |||
| ce7a7e4e23 | |||
| cba5ba03e3 | |||
| 49081a4e24 | |||
| d6371f2fcd | |||
| 7625cbdf47 | |||
| 0ffec67ca3 | |||
| bd132f2614 | |||
| dcb0df4ac1 | |||
| 02f3504055 | |||
| f23ee5e4a8 | |||
| 77aa85c589 | |||
| a618946b7e | |||
| f9de608dea | |||
| 906dc8f5b2 | |||
| 12971935ab | |||
| 78c139b8b5 | |||
| c161978852 | |||
| cb37a7166a | |||
| 488445a679 | |||
| 97786afdd8 | |||
| b1a5e8a6ae | |||
| 0ece10af43 | |||
| d1a02b149c | |||
| 94ee274aeb | |||
| 3c259fc552 | |||
| 210be4b749 | |||
| 464649e67e | |||
| c8dca936b1 | |||
| 4f871906bc | |||
| f7ea2fc346 | |||
| 5cb633a48e | |||
| b985d2eb8e | |||
| 014908a8d7 | |||
| f599f8f3d3 | |||
| b2010220f9 | |||
| c2e975c4e6 | |||
| 6aa250ee5d | |||
| 8f1e3ff75c | |||
| 5438dfb751 | |||
| 4bd16d6f47 | |||
| 9ee337469d | |||
| 813c41ff6c | |||
| 247f14f955 | |||
| bd906d468d | |||
| fc11ed3838 | |||
| 2a85c861e0 | |||
| 9554f9a16e | |||
| d72b428061 | |||
| fea17b4a0e | |||
| 9038e44a68 | |||
| 3b8299e3f2 | |||
| f5cdf0f056 | |||
| 224feff7c4 | |||
| ce2c9e7e26 | |||
| cb80f47112 | |||
| b4ac939537 | |||
| 8a96b80aec | |||
| 333a090975 | |||
| 5a78a17e49 | |||
| 9d6af6afa4 | |||
| 8c3e06798c | |||
| 5882b64612 | |||
| 309c4fe6f0 | |||
| d9555f138b | |||
| b47cfbee7c | |||
| 5504245f7b | |||
| f64b50d71b | |||
| a7b2ea94e5 | |||
| 7b775c2582 | |||
| 12a931a220 | |||
| 90100c1365 | |||
| 139c9febe4 | |||
| 06290907a9 | |||
| 1c8175a61a | |||
| fe91698ed6 | |||
| 068c32df39 | |||
| a2885d1662 | |||
| 38a3dbb4c4 | |||
| 43383d4fba | |||
| 40c4c3918a | |||
| 754ea99d86 | |||
| 37a1b80d5a | |||
| f95d04fea0 | |||
| 08b80da931 | |||
| f6fb1a7cdd | |||
| 5bcc0016a6 | |||
| 771174b5c3 | |||
| 44cd984589 | |||
| c445d98188 | |||
| 27e7b0558a | |||
| fb975eeb7c | |||
| e556aca833 | |||
| 73bd8a264b | |||
| 077338fcc8 | |||
| b671a77145 | |||
| 30d09a7d7e | |||
| ee02fb4fce | |||
| 010133f6df | |||
| 3b0d8f0e6f | |||
| 4d59b059ee | |||
| 1be6d94267 | |||
| 302e9b45ab | |||
| 7211c9934a | |||
| 3bda0ce123 | |||
| 62320947d9 | |||
| 0cb9bb567e | |||
| b14adcaf83 | |||
| 5bd3da40a5 | |||
| abcbdad530 | |||
| debdea40ea | |||
| 775f24f049 | |||
| 470e360942 | |||
| f15398efc3 | |||
| 4333a44e65 | |||
| 8b4b0d67d7 | |||
| 9200e353bf | |||
| 31124312f6 | |||
| 4f622fa7cd | |||
| 4e4cf9e25e | |||
| 6da2f3c38f | |||
| 1b39d364bd | |||
| 856c569458 | |||
| 92807d0bb9 | |||
| 713658c07b | |||
| f5700808c5 | |||
| 7eb51d0507 | |||
| 02c37c401a | |||
| 33e3d1ebca | |||
| 1f2c59a1b6 | |||
| 65d2a4e00c | |||
| 7d4de3d9e4 | |||
| 9977ef0160 | |||
| 9a55d125f6 | |||
| 766aaf448d | |||
| f14d78afeb | |||
| 0261f04b90 | |||
| 9b49e7bec9 | |||
| 93cb113cef | |||
| 4216d35e4b | |||
| 863645c671 | |||
| 5592f5225b | |||
| 8db7487c89 | |||
| 0fd73a2976 | |||
| 80865407e0 | |||
| 28f4284bd7 | |||
| 7affee529b | |||
| 79c7e9446e | |||
| efb629a33a | |||
| 2ddc91a0e8 | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 | |||
| 1e35d33ecb | |||
| c5bf4edb10 | |||
| cce8bb0e1c | |||
| 02c66c5fc1 | |||
| 33390cc457 | |||
| b2afb35527 | |||
| 828148909d | |||
| 454145fe65 | |||
| 1291dc1f11 | |||
| c7d7f61146 | |||
| 13b6845e7e | |||
| d1d97014b4 | |||
| b31747124b | |||
| 49bec25ae5 | |||
| d66d0cb904 | |||
| 3193f3aa95 | |||
| ee5460a152 | |||
| b09a066fb5 | |||
| 423fad730c | |||
| 5ae2d6d3f6 | |||
| 993f046dd9 | |||
| 9b640cf97d | |||
| 2200d100d0 | |||
| 68ef893818 | |||
| 7ab3dd04ad | |||
| 4f201f87a9 | |||
| 5aa276c117 | |||
| 4605308be2 | |||
| c64d813347 | |||
| 6e4a972bb9 | |||
| 835149e42b | |||
| 0f3c30118c | |||
| 35d31d8ec1 | |||
| 0374d29232 | |||
| d90da82be2 | |||
| c06186a9d9 | |||
| c5680a206e | |||
| edaee143d9 | |||
| 4184149b90 | |||
| 00f063b381 | |||
| ea010ab3a4 | |||
| 65c5c7e3f6 | |||
| ee69a91f26 | |||
| 7456598cde | |||
| c228fc4886 | |||
| bb3f4cc3c7 | |||
| 8d9727be9f | |||
| 6401eeaea0 | |||
| e7ecee5df2 | |||
| 02f2725154 | |||
| 79c1544072 | |||
| 8cac010bac | |||
| 193f6be99c | |||
| 2a9f8e3a2c | |||
| 2c814120c7 | |||
| 18fccac6ff | |||
| 360e7b412d | |||
| ad596fb42b | |||
| 74e92f73e0 | |||
| 8f3465c951 | |||
| 466b75b19d | |||
| 833dd2ed7f | |||
| bbe985b4d0 | |||
| 5b1321a8ca | |||
| 52c98a4eeb | |||
| b6f85c2250 | |||
| 6e8aefcfa0 | |||
| f1f0f47ee2 | |||
| 203b9f59b7 | |||
| 48bcbd09ef | |||
| d86f732df3 | |||
| a817fe5e6c | |||
| 381f6c47b4 | |||
| b6e14b5a74 | |||
| 36979c9641 | |||
| 4e067a2014 | |||
| 5913c1db0b | |||
| 88f572cfce | |||
| 2271f9b222 | |||
| 8a36abd328 | |||
| 532292b5f3 | |||
| 89e3141079 | |||
| 7a1f9764a2 | |||
| b91dd78ab3 | |||
| dba5da7c10 | |||
| aea36d7630 | |||
| 03474816ea | |||
| 5a9ed4d350 | |||
| 4204b9d087 | |||
| 8fad13365b | |||
| 5841b04fe7 | |||
| e12ec8681b | |||
| a3e2cc0dcd | |||
| 648183cecb | |||
| 58d18be590 | |||
| dc9fd126a0 | |||
| 794137b33b | |||
| 0e08b7330f | |||
| ced3d5e02a | |||
| 6e826f3e22 | |||
| 8bb1cfaa3b | |||
| 691a7d93c8 | |||
| ca6da05d07 | |||
| 23483ea013 | |||
| 69c05ae3fc | |||
| 59ad593d9c | |||
| 6b9136e797 | |||
| 80dc21d8f7 | |||
| c8a40450e5 | |||
| e84938b309 | |||
| c821d49386 | |||
| f683eef5f9 | |||
| a0a6963beb | |||
| da35eac93f | |||
| c944048bcf | |||
| c2351e308f | |||
| 8de40be6ea | |||
| 2ed8fe5960 | |||
| 1ea9137c01 | |||
| 77d3289d27 | |||
| e6607fb58d | |||
| 128e2550ef | |||
| e8d273ae5b | |||
| 7ec1b6e580 | |||
| c4fa5e6619 | |||
| 6ec17e5d55 | |||
| 0405670cab | |||
| caf727ecb7 | |||
| 9c38358197 | |||
| dd6c95fdc7 | |||
| 6f4b9504e5 | |||
| dbfbb13ccc | |||
| 99b2e78d75 | |||
| 023f76f6fc | |||
| 69d7c2add7 | |||
| cf651bc41b | |||
| 8d80365ef4 | |||
| 3ea8895299 | |||
| 3304e79827 | |||
| cf2c1bf425 | |||
| e0810753f2 | |||
| 7243bd9e12 | |||
| dd50d457b0 | |||
| 592ef7433a | |||
| fd97d789ef | |||
| 7111f93836 | |||
| e770415fd5 | |||
| 96dc560833 | |||
| eb4ff91c2d | |||
| a81c04fd31 | |||
| 63e2054f50 | |||
| 09a38f1984 | |||
| 065b108bba | |||
| 39fdd67123 | |||
| 44b6938b2a | |||
| fc7e4cf93e | |||
| 43c08387f1 | |||
| 407ac2d8aa | |||
| 9445b4d940 | |||
| 0409e6884d | |||
| ace77a5cf7 | |||
| f5a487ffcd | |||
| 455b5d3165 | |||
| a2c4c7a64b | |||
| e93fa06d05 | |||
| fa4ec98e1d | |||
| 4c9a4309ce | |||
| c0ba62b972 | |||
| 8cb9361149 | |||
| 642950a763 | |||
| df1f42d80f | |||
| 0c18d06c9f | |||
| 17c1e9b083 | |||
| 0b2e2cbab0 | |||
| 79d0077f63 | |||
| 0aa4031dd0 | |||
| f971aa0719 | |||
| 61b7caa0b5 | |||
| 98c29075b4 | |||
| 6d50023457 | |||
| e8b19afa8f | |||
| 3da0dea8e7 | |||
| c7aa47c144 | |||
| 7be95bcd6a | |||
| 3f4fb3f9db | |||
| 6702ce0d3f | |||
| a9895a1e8a | |||
| 75c7ac6a2c | |||
| 0eb71d0fc7 | |||
| deee40c572 | |||
| 8b5655ed53 | |||
| b9525b5878 | |||
| 2b8d1998d6 | |||
| 391cfbcfe7 | |||
| fe0c74f4df | |||
| 6d32bc9cb0 | |||
| 925c2423de | |||
| 71cb419570 | |||
| edc93ceff1 | |||
| 86a468c4d8 | |||
| f05d2ab551 | |||
| bef6cfbffe | |||
| fe9a936867 | |||
| d5669ca934 | |||
| 04f0a771a3 | |||
| c50554d907 | |||
| 3f5ad8441f | |||
| cf6e5b3604 | |||
| cbcf191fdb | |||
| ba0bbdafaa | |||
| a288cb5a0c | |||
| 57ff57595a | |||
| 584fdefe6f | |||
| c310ef82f4 | |||
| 4e85a37590 | |||
| fe9b051d1a | |||
| 33f1b269e9 | |||
| 0d68b0bb20 | |||
| 4e56998d3c | |||
| d625bc86fc | |||
| 5f437a09b0 | |||
| 7d4496eafc | |||
| 1f2de535aa | |||
| 529d55b1c3 | |||
| deb5b144c3 | |||
| cd3cca0006 | |||
| 08a7080e11 | |||
| f2579c41b6 | |||
| c4830c147d | |||
| 22c23cb44c | |||
| 60ce33cde8 | |||
| 6ea8247e0f | |||
| 987242b3e8 | |||
| fafb04ed59 | |||
| 87d5b27668 | |||
| c7a7900ff2 | |||
| f2a4011371 | |||
| 4bc8faa01c | |||
| 5d5b33f195 | |||
| 3fc06accbd | |||
| 677526d72e | |||
| bce9c639d1 | |||
| 48adb9521c | |||
| 9b3a4655f1 | |||
| bd79e24842 | |||
| d67e035c18 | |||
| f8470404e3 | |||
| 651751405d | |||
| 44f7bb71c1 | |||
| b26d948b76 | |||
| 2180fb8e7b | |||
| 3db4458e6c | |||
| 42e2dde739 | |||
| a1b274e6f8 | |||
| 27e1692848 | |||
| 73567f3570 | |||
| 91238441a6 | |||
| faf964691b | |||
| 4a7f760d25 | |||
| f76e18201a | |||
| 331533deff | |||
| 309860558d | |||
| b4a4ef0b3c | |||
| e916b99742 | |||
| 88b8883122 | |||
| ac1b802820 | |||
| 5ee4c1082a | |||
| 8a70d70006 | |||
| 91b4634363 | |||
| 4f60bde502 | |||
| 0d3b6d430e | |||
| a7cf21e157 | |||
| e4a16830f1 | |||
| 103f764624 | |||
| 7a7075d86f | |||
| c764732aea | |||
| 72af084dc1 | |||
| 65a42fd549 | |||
| ad8e6b1fb6 | |||
| 041cda905d | |||
| 2ad6c42143 | |||
| 60c7f847c1 | |||
| 7ab8a08efa | |||
| 7388e69b5c | |||
| 56c0658e6f | |||
| 06a2592836 | |||
| 4829308f2d | |||
| eec6cd5234 | |||
| fc37ae4452 | |||
| 5fcef6773c | |||
| a5b28c2af2 | |||
| d01dfa57b7 | |||
| 198ca749fd | |||
| 75f9fd5208 | |||
| 282f3f7eb2 | |||
| 4678bd1049 | |||
| 5fb0470b44 | |||
| 6bf92aa7de | |||
| 0a5530d0be | |||
| dc7e0f6d2e | |||
| b4be28d4c3 | |||
| 7624c4f7b3 | |||
| 720f062cd0 | |||
| b5177adb5b | |||
| a772767e9b | |||
| fd19fa89c4 | |||
| 0cfa7b39db | |||
| 5e2e0d6965 | |||
| f92d84746b | |||
| f2f5eb6f16 |
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/client": patch
|
|
||||||
"@opencode-ai/protocol": patch
|
|
||||||
"@opencode-ai/cli": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
packages/core/migration/**/snapshot.json linguist-generated
|
packages/core/migration/**/snapshot.json linguist-generated
|
||||||
packages/core/src/database/migration.gen.ts linguist-generated
|
packages/core/src/database/migration.gen.ts linguist-generated
|
||||||
|
packages/core/src/**/*.txt text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: deploy-www
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- v2
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-www-${{ 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: Build
|
||||||
|
working-directory: packages/www
|
||||||
|
run: bun run build
|
||||||
|
env:
|
||||||
|
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||||
|
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
working-directory: packages/www
|
||||||
|
run: bun run deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
@@ -90,11 +90,18 @@ jobs:
|
|||||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build legacy CLI
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
|
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
||||||
|
- name: Build preview CLI
|
||||||
id: build
|
id: build
|
||||||
run: |
|
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
|
||||||
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
@@ -102,6 +109,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: |
|
path: |
|
||||||
@@ -109,6 +117,7 @@ jobs:
|
|||||||
packages/opencode/dist/opencode-linux*
|
packages/opencode/dist/opencode-linux*
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist/opencode-windows*
|
path: packages/opencode/dist/opencode-windows*
|
||||||
@@ -121,6 +130,55 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
|
build-node-cli:
|
||||||
|
needs: version
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
settings:
|
||||||
|
- target: linux-arm64
|
||||||
|
host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
- target: linux-x64
|
||||||
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
- target: darwin-arm64
|
||||||
|
host: macos-26
|
||||||
|
- target: windows-arm64
|
||||||
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
- target: windows-x64
|
||||||
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
with:
|
||||||
|
install-flags: --os=* --cpu=*
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "26.4.0"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
|
||||||
|
- name: Verify service lifecycle
|
||||||
|
if: matrix.settings.target != 'windows-arm64'
|
||||||
|
working-directory: packages/cli
|
||||||
|
run: bun run script/service-smoke.ts --node
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: opencode-node-cli-${{ matrix.settings.target }}
|
||||||
|
path: packages/cli/dist/node/cli-node-*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
sign-cli-windows:
|
sign-cli-windows:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
@@ -413,6 +471,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
|
- build-node-cli
|
||||||
- sign-cli-windows
|
- sign-cli-windows
|
||||||
- build-electron
|
- build-electron
|
||||||
if: always() && !failure() && !cancelled()
|
if: always() && !failure() && !cancelled()
|
||||||
@@ -441,11 +500,13 @@ jobs:
|
|||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'v2'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
@@ -461,6 +522,12 @@ jobs:
|
|||||||
name: opencode-preview-cli
|
name: opencode-preview-cli
|
||||||
path: packages/cli/dist
|
path: packages/cli/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with:
|
||||||
|
pattern: opencode-node-cli-*
|
||||||
|
path: packages/cli/dist/node
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -78,11 +78,30 @@ jobs:
|
|||||||
bun run script/build.ts --single --skip-install
|
bun run script/build.ts --single --skip-install
|
||||||
bun run script/service-smoke.ts
|
bun run script/service-smoke.ts
|
||||||
|
|
||||||
|
- name: Setup Node build runtime
|
||||||
|
if: always()
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "26.4.0"
|
||||||
|
|
||||||
|
- name: Verify Node build
|
||||||
|
if: always()
|
||||||
|
timeout-minutes: 15
|
||||||
|
working-directory: packages/cli
|
||||||
|
run: |
|
||||||
|
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||||
|
bun run script/service-smoke.ts --node
|
||||||
|
|
||||||
- name: Check generated client
|
- name: Check generated client
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
run: bun run check:generated
|
run: bun run check:generated
|
||||||
|
|
||||||
|
- name: Check generated documentation
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
working-directory: packages/www
|
||||||
|
run: bun run check:generated
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ node_modules
|
|||||||
playground
|
playground
|
||||||
tmp
|
tmp
|
||||||
dist
|
dist
|
||||||
|
dist-node
|
||||||
ts-dist
|
ts-dist
|
||||||
.turbo
|
.turbo
|
||||||
.typecheck-profiles
|
.typecheck-profiles
|
||||||
@@ -25,6 +26,7 @@ Session.vim
|
|||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
.scripts
|
.scripts
|
||||||
|
.cache
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
# Local dev files
|
# Local dev files
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/claude-opus-4-8
|
model: opencode/gpt-5.6-sol
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "test.tui-discovery-smoke",
|
||||||
|
setup(_context: Context) {
|
||||||
|
// context.ui.toast.show({
|
||||||
|
// title: "TUI plugin discovery works",
|
||||||
|
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||||
|
// variant: "success",
|
||||||
|
// duration: 30_000,
|
||||||
|
// })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: ideal-pseudocode
|
||||||
|
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ideal Pseudocode
|
||||||
|
|
||||||
|
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||||
|
|
||||||
|
## Loop
|
||||||
|
|
||||||
|
One function per round. Never touch code before the user picks a direction.
|
||||||
|
|
||||||
|
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||||
|
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||||
|
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||||
|
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||||
|
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||||
|
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||||
|
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||||
|
8. **Offer the next target** and stop.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||||
|
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||||
|
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||||
|
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||||
|
|
||||||
|
## Example round
|
||||||
|
|
||||||
|
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||||
|
|
||||||
|
> `runStep` it is. The ideal pseudocode:
|
||||||
|
>
|
||||||
|
> ```ts
|
||||||
|
> // One logical step: one settled model call, however many attempts it takes.
|
||||||
|
> // An attempt can end without settling in two ways:
|
||||||
|
> // transient provider failure -> retry the same call, same assistant message
|
||||||
|
> // compaction rewrote history -> rebuild the request and call again
|
||||||
|
> function runStep(session, promotable, step) {
|
||||||
|
> while (true) {
|
||||||
|
> const result = callModel(session, promotable, step)
|
||||||
|
>
|
||||||
|
> if (result.completed) return result
|
||||||
|
>
|
||||||
|
> if (result.retryable) {
|
||||||
|
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||||
|
> promotable = none // never re-promote on a second attempt
|
||||||
|
> continue
|
||||||
|
> }
|
||||||
|
>
|
||||||
|
> // compaction restarted the step: fresh request from rewritten history
|
||||||
|
> promotable = none
|
||||||
|
> step = result.step
|
||||||
|
> }
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Comparing against the real thing, three deltas:
|
||||||
|
>
|
||||||
|
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||||
|
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||||
|
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||||
|
>
|
||||||
|
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||||
|
>
|
||||||
|
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
|
||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
|
|
||||||
|
## Live V2 TUI Testing
|
||||||
|
|
||||||
|
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||||
|
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||||
|
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
|
||||||
|
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||||
|
|
||||||
## Branch Names
|
## Branch Names
|
||||||
|
|
||||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||||
@@ -19,8 +25,6 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
|
|||||||
|
|
||||||
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||||
|
|
||||||
Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user.
|
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
### General Principles
|
### General Principles
|
||||||
@@ -63,6 +67,7 @@ const { a, b } = obj
|
|||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
|
- 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 star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
|
- 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-ai/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.
|
- 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.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@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-builder", "electron-publish"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@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-builder", "electron-publish"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# V1 to V2 Database Migration
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||||
|
- Remove migrations that exist only on the V2 branch.
|
||||||
|
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||||
|
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||||
|
- Test the migration against a populated database at the exact `dev` schema.
|
||||||
|
|
||||||
|
## Preserve
|
||||||
|
|
||||||
|
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
|
||||||
|
|
||||||
|
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||||
|
workspace relationships.
|
||||||
|
|
||||||
|
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
||||||
|
generated migration must not drop the table.
|
||||||
|
|
||||||
|
## Truncate
|
||||||
|
|
||||||
|
Truncate these pre-launch V2 tables before applying schema changes:
|
||||||
|
|
||||||
|
- `event`
|
||||||
|
- `event_sequence`
|
||||||
|
- `session_message`
|
||||||
|
|
||||||
|
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
||||||
|
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
||||||
|
and `part` rows rather than retaining its pre-launch V2 contents.
|
||||||
|
|
||||||
|
## Message Backfill
|
||||||
|
|
||||||
|
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
|
||||||
|
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||||
|
V2 session APIs, which read `session_message`.
|
||||||
|
|
||||||
|
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||||
|
avoid rewriting other persisted state that may refer to a message.
|
||||||
|
|
||||||
|
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||||
|
contiguous `session_message.seq` values starting at `0`.
|
||||||
|
|
||||||
|
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
|
||||||
|
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||||
|
payload.
|
||||||
|
|
||||||
|
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||||
|
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||||
|
part mappings must be decided explicitly before implementing the backfill.
|
||||||
|
|
||||||
|
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||||
|
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||||
|
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||||
|
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||||
|
|
||||||
|
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||||
|
admitted compaction input ID and preserves references to the initiating message.
|
||||||
|
|
||||||
|
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
|
||||||
|
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
|
||||||
|
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
|
||||||
|
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||||
|
assistant row.
|
||||||
|
|
||||||
|
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
||||||
|
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
||||||
|
before migrated history. The `event` table remains empty.
|
||||||
|
|
||||||
|
## Drop
|
||||||
|
|
||||||
|
Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||||
|
|
||||||
|
- `session_input`
|
||||||
|
- `session_context_epoch`
|
||||||
|
|
||||||
|
Do not transfer `session_input` rows into `session_pending`.
|
||||||
|
|
||||||
|
## Create Empty
|
||||||
|
|
||||||
|
Let the generated migration create these tables empty:
|
||||||
|
|
||||||
|
- `instruction_blob`
|
||||||
|
- `instruction_entry`
|
||||||
|
- `instruction_state`
|
||||||
|
- `session_pending`
|
||||||
|
- `kv`
|
||||||
|
|
||||||
|
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
|
||||||
|
|
||||||
|
## Fork Storage
|
||||||
|
|
||||||
|
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
|
||||||
|
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
|
||||||
|
|
||||||
|
- `before`: copy messages before the identified message.
|
||||||
|
- `through`: copy messages through the identified message.
|
||||||
|
|
||||||
|
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
|
||||||
|
schema.
|
||||||
|
|
||||||
|
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||||
|
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
||||||
|
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
||||||
|
|
||||||
|
- Preserved rows and encoded values remain unchanged.
|
||||||
|
- Todo rows remain available in the unchanged `todo` table.
|
||||||
|
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
||||||
|
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
||||||
|
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
||||||
|
- Dropped tables no longer exist.
|
||||||
|
- New tables exist and are empty.
|
||||||
|
- The final schema has no ungenerated changes.
|
||||||
+1
-1
@@ -15,6 +15,6 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@octokit/graphql": "9.0.1",
|
"@octokit/graphql": "9.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*"
|
"@opencode-ai/sdk": "1.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
|||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
|
if ($app.stage === "production") {
|
||||||
|
new cloudflare.DnsRecord("TrustCenter", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "trust.opencode.ai",
|
||||||
|
type: "CNAME",
|
||||||
|
content: "3a69a5bb27875189.vercel-dns-016.com",
|
||||||
|
proxied: false,
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
new cloudflare.DnsRecord("TrustCenterVerification", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "opencode.ai",
|
||||||
|
type: "TXT",
|
||||||
|
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|||||||
+66
-33
@@ -8,6 +8,8 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
copyDesktopItems
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
||||||
|
name = "ai.opencode.desktop";
|
||||||
|
desktopName = "OpenCode";
|
||||||
|
exec = "opencode-desktop %U";
|
||||||
|
icon = "ai.opencode.desktop";
|
||||||
|
# Electron 41 derives X11 WM_CLASS from app.name.
|
||||||
|
startupWMClass = "OpenCode";
|
||||||
|
categories = [ "Development" ];
|
||||||
|
});
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# https://github.com/electron/electron/issues/31121
|
postPatch =
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
''
|
||||||
BASE_PATH=packages/desktop
|
substituteInPlace packages/script/src/index.ts \
|
||||||
FILES=(src/main/windows.ts)
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
for file in "''${FILES[@]}"; do
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
substituteInPlace $BASE_PATH/$file \
|
''
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
# https://github.com/electron/electron/issues/31121
|
||||||
done
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
'';
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
|
BASE_PATH=packages/desktop
|
||||||
|
FILES=(src/main/windows.ts)
|
||||||
|
for file in "''${FILES[@]}"; do
|
||||||
|
substituteInPlace $BASE_PATH/$file \
|
||||||
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
mkdir -p $out/Applications
|
||||||
mkdir -p $out/Applications
|
mv dist/mac*/*.app $out/Applications
|
||||||
mv dist/mac*/*.app $out/Applications
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
mkdir -p $out/opt/opencode-desktop
|
||||||
mkdir -p $out/opt/opencode-desktop
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
install -Dm644 resources/icons/32x32.png \
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
--inherit-argv0 \
|
install -Dm644 resources/icons/64x64.png \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
install -Dm644 resources/icons/128x128.png \
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||||
''
|
install -Dm644 resources/icons/128x128@2x.png \
|
||||||
+ ''
|
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||||
runHook postInstall
|
install -Dm644 resources/icons/icon.png \
|
||||||
'';
|
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||||
|
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||||
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
|
--inherit-argv0 \
|
||||||
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
|
''
|
||||||
|
+ ''
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
|
||||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
|
||||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
|
||||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-13
@@ -8,6 +8,7 @@
|
|||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||||
|
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"dev:www": "bun run --cwd packages/www dev",
|
"dev:www": "bun run --cwd packages/www dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||||
@@ -33,24 +34,24 @@
|
|||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/sdk/js",
|
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.101",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.5",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
|
"@standard-schema/spec": "1.1.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@corvu/drawer": "0.2.4",
|
"@corvu/drawer": "0.2.4",
|
||||||
@@ -69,12 +70,13 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.101",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
"get-east-asian-width": "1.6.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.6",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
@@ -85,9 +87,11 @@
|
|||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"resolve.exports": "2.0.3",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"string-width": "7.2.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "1.18.5",
|
||||||
"heap-snapshot-toolkit": "1.1.3",
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
@@ -149,21 +153,22 @@
|
|||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:",
|
||||||
|
"effect": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.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`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `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.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ const response = yield * LLMClient.generate(request)
|
|||||||
|
|
||||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||||
|
|
||||||
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
|
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
|
||||||
|
|
||||||
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
|
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
|
|||||||
|
|
||||||
A route is the registered, runnable composition of four orthogonal pieces:
|
A route is the registered, runnable composition of four orthogonal pieces:
|
||||||
|
|
||||||
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
|
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
|
||||||
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
|
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
|
||||||
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
|
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
|
||||||
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
|
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
|
||||||
@@ -74,7 +76,7 @@ export const route = Route.make({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` 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 `LLMError`s.
|
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||||
|
|
||||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ const selected = model("gpt-5", {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`.
|
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
|
||||||
|
|
||||||
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
||||||
|
|
||||||
@@ -136,15 +138,15 @@ Do not expose `Route` in provider package settings. Route composition stays an i
|
|||||||
packages/ai/src/
|
packages/ai/src/
|
||||||
schema/ canonical Schema model, split by concern
|
schema/ canonical Schema model, split by concern
|
||||||
ids.ts branded IDs, literal types, ProviderMetadata
|
ids.ts branded IDs, literal types, ProviderMetadata
|
||||||
options.ts Generation/Provider/Http options, Limits, Model, cache policy
|
options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
|
||||||
messages.ts content parts, Message, ToolDefinition, LLMRequest
|
messages.ts content parts, Message, ToolDefinition, LLMRequest
|
||||||
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
|
events.ts Usage, individual events, LLMEvent, LLMResponse
|
||||||
errors.ts error reasons, LLMError, ToolFailure
|
errors.ts error reasons, AIError, ToolFailure
|
||||||
index.ts barrel
|
index.ts barrel
|
||||||
llm.ts request constructors and convenience helpers
|
llm.ts request constructors and convenience helpers
|
||||||
route/
|
route/
|
||||||
index.ts @opencode-ai/ai/route advanced barrel
|
index.ts @opencode-ai/ai/route advanced barrel
|
||||||
client.ts Route.make + LLMClient.prepare/stream/generate
|
client.ts Route.make + LLMClient.stream/generate
|
||||||
executor.ts RequestExecutor service + transport error mapping
|
executor.ts RequestExecutor service + transport error mapping
|
||||||
protocol.ts Protocol type + Protocol.make
|
protocol.ts Protocol type + Protocol.make
|
||||||
endpoint.ts Endpoint type + Endpoint.path
|
endpoint.ts Endpoint type + Endpoint.path
|
||||||
@@ -158,13 +160,14 @@ packages/ai/src/
|
|||||||
protocols/
|
protocols/
|
||||||
shared.ts ProviderShared toolkit used inside protocol impls
|
shared.ts ProviderShared toolkit used inside protocol impls
|
||||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||||
openai-responses.ts
|
open-responses.ts provider-neutral Responses protocol baseline
|
||||||
|
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||||
anthropic-messages.ts
|
anthropic-messages.ts
|
||||||
gemini.ts
|
gemini.ts
|
||||||
bedrock-converse.ts
|
bedrock-converse.ts
|
||||||
bedrock-event-stream.ts framing for AWS event-stream binary frames
|
bedrock-event-stream.ts framing for AWS event-stream binary frames
|
||||||
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
|
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
|
||||||
openai-compatible-responses.ts route that reuses OpenAIResponses.protocol, no canonical URL
|
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
|
||||||
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
|
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
|
||||||
providers/
|
providers/
|
||||||
openai-compatible.ts generic Chat helper + family model helpers
|
openai-compatible.ts generic Chat helper + family model helpers
|
||||||
@@ -175,7 +178,7 @@ packages/ai/src/
|
|||||||
tool-runtime.ts narrow one-call typed tool dispatcher
|
tool-runtime.ts narrow one-call typed tool dispatcher
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
|
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
|
||||||
|
|
||||||
### Shared protocol helpers
|
### Shared protocol helpers
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ const get_weather = tool({
|
|||||||
|
|
||||||
const tools = { get_weather, get_time, ... }
|
const tools = { get_weather, get_time, ... }
|
||||||
const events = yield* LLM.stream(
|
const events = yield* LLM.stream(
|
||||||
LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }),
|
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||||
).pipe(Stream.runCollect)
|
).pipe(Stream.runCollect)
|
||||||
|
|
||||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ contains identity, capabilities, pricing metadata, provider-specific option
|
|||||||
types, reusable request-behavior defaults, and hidden execution behavior.
|
types, reusable request-behavior defaults, and hidden execution behavior.
|
||||||
|
|
||||||
Normal users do not need to learn the current `Route` composite. Protocol,
|
Normal users do not need to learn the current `Route` composite. Protocol,
|
||||||
endpoint, auth, transport, and hooks are bound behind `Model`.
|
endpoint, auth, transport, and hooks are bound behind `LanguageModel`.
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
|
|
||||||
@@ -315,7 +315,8 @@ const longer = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no `LLM.updateRequest(...)` helper and no request Schema class.
|
There is no `LLM.updateRequest(...)` helper. The current Schema-backed implementation
|
||||||
|
uses `LLMRequest.update(...)` when canonical request data must be derived.
|
||||||
|
|
||||||
### Conversation history
|
### Conversation history
|
||||||
|
|
||||||
@@ -436,7 +437,7 @@ const call = Array.from(events).find(LLMEvent.is.toolCall)
|
|||||||
|
|
||||||
if (call && !call.providerExecuted) {
|
if (call && !call.providerExecuted) {
|
||||||
const dispatched = yield * ToolRuntime.dispatch(tools, call)
|
const dispatched = yield * ToolRuntime.dispatch(tools, call)
|
||||||
const followUp = LLM.updateRequest(request, {
|
const followUp = LLMRequest.update(request, {
|
||||||
messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })],
|
messages: [...request.messages, Message.assistant([call]), Message.tool({ ...call, result: dispatched.result })],
|
||||||
})
|
})
|
||||||
// Caller must invoke the provider again and repeat the loop.
|
// Caller must invoke the provider again and repeat the loop.
|
||||||
@@ -538,7 +539,7 @@ Hosted tools do not pretend to have local handlers, and callers do not inspect a
|
|||||||
|
|
||||||
### Run stream
|
### Run stream
|
||||||
|
|
||||||
`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, Requirements>`.
|
`LLM.stream` returns an Effect `Stream<RunEvent, AIError, Requirements>`.
|
||||||
Run events explicitly expose orchestration boundaries:
|
Run events explicitly expose orchestration boundaries:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -827,11 +828,11 @@ portable semantic guarantee.
|
|||||||
|
|
||||||
## Error Model
|
## Error Model
|
||||||
|
|
||||||
The Effect error channel is a tagged domain union rather than one `LLMError`
|
The Effect error channel is a tagged domain union rather than one `AIError`
|
||||||
wrapper with nested reasons. Illustrative categories:
|
wrapper with nested reasons. Illustrative categories:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type LLMError =
|
type AIError =
|
||||||
| AuthenticationError
|
| AuthenticationError
|
||||||
| InvalidRequestError
|
| InvalidRequestError
|
||||||
| UnsupportedCapabilityError
|
| UnsupportedCapabilityError
|
||||||
@@ -1078,7 +1079,7 @@ The redesign intentionally removes or changes these current concepts:
|
|||||||
| `LLM.generate` means one turn | `LLM.generate` means complete run |
|
| `LLM.generate` means one turn | `LLM.generate` means complete run |
|
||||||
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
|
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
|
||||||
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
|
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
|
||||||
| Public `Route` mental model | Hidden behind executable `Model` |
|
| Public `Route` mental model | Hidden behind executable `LanguageModel` |
|
||||||
| `Provider.make` structural helper | Experimental declarative `Provider.define` |
|
| `Provider.make` structural helper | Experimental declarative `Provider.define` |
|
||||||
| Schema classes as canonical values | Plain immutable values plus schema subpath |
|
| Schema classes as canonical values | Plain immutable values plus schema subpath |
|
||||||
| `LLM.updateRequest` | Object spread |
|
| `LLM.updateRequest` | Object spread |
|
||||||
@@ -1088,7 +1089,7 @@ The redesign intentionally removes or changes these current concepts:
|
|||||||
| `generateObject` | Typed `output` option on `generate` |
|
| `generateObject` | Typed `output` option on `generate` |
|
||||||
| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
|
| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
|
||||||
| `providerExecuted` dispatch check | Distinct hosted-tool constructors |
|
| `providerExecuted` dispatch check | Distinct hosted-tool constructors |
|
||||||
| One wrapped `LLMError` | Tagged domain error union |
|
| One wrapped `AIError` | Tagged domain error union |
|
||||||
|
|
||||||
OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
|
OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
|
||||||
prompt admission, persistence, permission, tool settlement, and continuation
|
prompt admission, persistence, permission, tool settlement, and continuation
|
||||||
|
|||||||
+230
-16
@@ -1,10 +1,11 @@
|
|||||||
# @opencode-ai/ai
|
# @opencode-ai/ai
|
||||||
|
|
||||||
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
|
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||||
|
import { RequestExecutor } from "@opencode-ai/ai/route"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||||
@@ -20,18 +21,215 @@ const program = Effect.gen(function* () {
|
|||||||
const response = yield* LLMClient.generate(request)
|
const response = yield* LLMClient.generate(request)
|
||||||
console.log(response.text)
|
console.log(response.text)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||||
|
|
||||||
|
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.
|
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||||
|
|
||||||
|
## Image generation
|
||||||
|
|
||||||
|
Use `Image.generate` with an image model for direct asset generation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Image, ImageInput } from "@opencode-ai/ai"
|
||||||
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
size: "1024x1024",
|
||||||
|
quality: "high", // inferred from the OpenAI image model
|
||||||
|
outputFormat: "webp",
|
||||||
|
future_option: true, // unknown native options pass through unchanged
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const response =
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Combine these product photos into one studio scene",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(firstBytes, "image/png"),
|
||||||
|
ImageInput.url("https://example.com/second.webp"),
|
||||||
|
ImageInput.file("file_123"),
|
||||||
|
],
|
||||||
|
options,
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
|
||||||
|
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
|
||||||
|
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
|
||||||
|
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
|
||||||
|
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
|
||||||
|
`ImageInput` for inpainting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
|
||||||
|
prompt,
|
||||||
|
images: [ImageInput.bytes(sourceBytes, "image/png")],
|
||||||
|
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
|
||||||
|
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
|
||||||
|
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
|
||||||
|
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
|
||||||
|
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
|
||||||
|
`InvalidRequest` before network I/O.
|
||||||
|
|
||||||
|
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||||
|
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
options: { quality: "medium" },
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
xAI image models use the same request API with xAI-native controls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
resolution: "1k",
|
||||||
|
responseFormat: "b64_json",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Google's current Gemini image models use the same direct API:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Google } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
|
const googleProgram = Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.images
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
|
||||||
|
future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
|
||||||
|
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
||||||
|
`generateContent` without a local allowlist.
|
||||||
|
|
||||||
|
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "user-123",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||||
|
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||||
|
remain available.
|
||||||
|
|
||||||
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const response = yield* LLM.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||||
|
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||||
|
tools: [OpenAI.imageGeneration({ quality: "high" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.message
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
|
||||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||||
|
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||||
|
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the deterministic test client from `@opencode-ai/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"
|
||||||
|
|
||||||
|
const testLLM = TestLLM.layer({
|
||||||
|
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||||
|
const programWithTestClient = Effect.gen(function* () {
|
||||||
|
const result = yield* program
|
||||||
|
const test = yield* TestLLM.Service
|
||||||
|
console.log(test.requests)
|
||||||
|
return result
|
||||||
|
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||||
|
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||||
|
available on the yielded `TestLLM.Service`.
|
||||||
|
|
||||||
## Caching
|
## Caching
|
||||||
|
|
||||||
@@ -39,7 +237,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
|||||||
|
|
||||||
### Auto placement
|
### Auto placement
|
||||||
|
|
||||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
|
||||||
|
|
||||||
|
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||||
|
|
||||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||||
|
|
||||||
@@ -67,7 +267,7 @@ cache: {
|
|||||||
|
|
||||||
### Manual hints
|
### Manual hints
|
||||||
|
|
||||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -83,8 +283,8 @@ LLM.request({
|
|||||||
|
|
||||||
| Protocol | `cache: "auto"` |
|
| Protocol | `cache: "auto"` |
|
||||||
| ----------------------- | ------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------------------------------------- |
|
||||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
|
||||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||||
|
|
||||||
@@ -104,7 +304,7 @@ const gateway = CloudflareAIGateway.configure({
|
|||||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||||
```
|
```
|
||||||
|
|
||||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||||
|
|
||||||
### Package-like entrypoints
|
### Package-like entrypoints
|
||||||
|
|
||||||
@@ -127,23 +327,37 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
|||||||
- `@opencode-ai/ai/providers/openai/responses`
|
- `@opencode-ai/ai/providers/openai/responses`
|
||||||
- `@opencode-ai/ai/providers/openai-compatible/responses`
|
- `@opencode-ai/ai/providers/openai-compatible/responses`
|
||||||
- `@opencode-ai/ai/providers/anthropic-compatible`
|
- `@opencode-ai/ai/providers/anthropic-compatible`
|
||||||
- `@opencode-ai/ai/providers/google-vertex`
|
- `@opencode-ai/ai/providers/google-vertex/gemini`
|
||||||
- `@opencode-ai/ai/providers/google-vertex/anthropic`
|
- `@opencode-ai/ai/providers/google-vertex/chat`
|
||||||
|
- `@opencode-ai/ai/providers/google-vertex/responses`
|
||||||
|
- `@opencode-ai/ai/providers/google-vertex/messages`
|
||||||
|
|
||||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. 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.
|
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. 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, defaults, and transports. 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.
|
||||||
|
|
||||||
Vertex Gemini and Vertex Anthropic are separate products with separate entrypoints. Both accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present.
|
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
|
||||||
|
|
||||||
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.
|
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
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||||
|
|
||||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic"
|
import { model } from "@opencode-ai/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"
|
||||||
|
|
||||||
|
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||||
|
|
||||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||||
```
|
```
|
||||||
@@ -168,7 +382,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
|
|||||||
|
|
||||||
## Effect
|
## Effect
|
||||||
|
|
||||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|||||||
+54
-52
@@ -1,36 +1,38 @@
|
|||||||
# LLM Provider Parity Status
|
# LLM Provider Parity Status
|
||||||
|
|
||||||
Last reviewed: 2026-07-15
|
Last reviewed: 2026-07-24
|
||||||
|
|
||||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||||
|
|
||||||
## Existing Status Sources
|
## Existing Status Sources
|
||||||
|
|
||||||
| File | What it tracks | Limitation |
|
| File | What it tracks | Limitation |
|
||||||
| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
|
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
|
||||||
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
|
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
|
||||||
|
|
||||||
## Current Implementation Snapshot
|
## Current Implementation Snapshot
|
||||||
|
|
||||||
| Native slice | Source | Current state | Main gaps |
|
| Native slice | Source | Current state | Main gaps |
|
||||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
|
||||||
| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
|
||||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
|
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||||
| Vertex Gemini | `src/protocols/google-vertex-gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||||
| Vertex Anthropic Messages | `src/protocols/google-vertex-anthropic.ts`, `src/providers/google-vertex-anthropic.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
|
||||||
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
| Vertex Responses | `src/protocols/open-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through Open Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and an explicit `store: false` Vertex default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
|
||||||
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
||||||
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
||||||
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
|
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
||||||
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
|
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
||||||
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
|
| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. |
|
||||||
|
| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. |
|
||||||
|
| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. |
|
||||||
|
|
||||||
## V2 Runner Status
|
## V2 Runner Status
|
||||||
|
|
||||||
@@ -54,53 +56,53 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
|||||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||||
| `@ai-sdk/google-vertex/maas` | Vertex MaaS OpenAI-compatible namespace/facade | Missing | Decide native Chat/Responses selection, endpoint derivation, auth, and catalog mapping. |
|
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||||
| `@ai-sdk/google-vertex/xai` | Vertex xAI OpenAI-compatible namespace/facade | Missing | Decide whether this composes the generic compatible bases or the xAI facade, then add endpoint/auth mapping and tests. |
|
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
|
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||||
|
|
||||||
## Highest-Risk Gaps
|
## Highest-Risk Gaps
|
||||||
|
|
||||||
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
|
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
|
||||||
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
2. The Open Responses adapter is available through a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
||||||
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
|
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
|
||||||
4. Vertex Gemini and Vertex Anthropic now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
|
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
|
||||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||||
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
||||||
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
||||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native boundaries remain for Vertex MaaS, Vertex xAI, and Bedrock Mantle.
|
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
|
||||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
|
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
|
||||||
|
|
||||||
## Native Namespace Shape
|
## Native Namespace Shape
|
||||||
|
|
||||||
These are implementation/API slices, not separate npm packages.
|
These are implementation/API slices, not separate npm packages.
|
||||||
|
|
||||||
| API slice | Package-like entrypoint | Purpose |
|
| API slice | Package-like entrypoint | Purpose |
|
||||||
| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||||
| OpenAI-compatible Responses | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex` | Vertex Gemini API. |
|
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||||
| Vertex Anthropic Messages | `@opencode-ai/ai/providers/google-vertex/anthropic` | Vertex-hosted Anthropic Messages API. |
|
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||||
| Vertex MaaS | Missing | Vertex OpenAI-compatible MaaS APIs. |
|
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||||
| Vertex xAI | Missing | Vertex-hosted xAI APIs. |
|
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||||
|
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||||
|
|
||||||
## Suggested Next Work Slices
|
## Suggested Next Work Slices
|
||||||
|
|
||||||
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
|
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
|
||||||
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
|
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
|
||||||
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
|
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
|
||||||
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini and Vertex Anthropic entrypoints.
|
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
|
||||||
5. Add native Vertex MaaS and Vertex xAI entrypoints by composing the compatible bases and shared Vertex auth/endpoint setup.
|
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
|
||||||
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
|
6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||||
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
|
||||||
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Keep durable identity separate from runtime capability:
|
|||||||
|
|
||||||
- Durable identity is small serializable data like `{ providerID, modelID }` for
|
- Durable identity is small serializable data like `{ providerID, modelID }` for
|
||||||
config, sessions, logs, and catalogs.
|
config, sessions, logs, and catalogs.
|
||||||
- Runtime capability is a `Model` with a route value, protocol, transport, auth,
|
- Runtime capability is a `LanguageModel` with a route value, protocol, transport, auth,
|
||||||
and defaults. It is allowed to contain functions and schemas.
|
and defaults. It is allowed to contain functions and schemas.
|
||||||
- If persisted identity needs to become executable, resolve it through an app
|
- If persisted identity needs to become executable, resolve it through an app
|
||||||
boundary first. Do not make `LLMRequest` recover behavior from a global route
|
boundary first. Do not make `LLMRequest` recover behavior from a global route
|
||||||
@@ -137,7 +137,7 @@ starts hiding the real provider-specific config.
|
|||||||
- accepts model id only
|
- accepts model id only
|
||||||
- returns executable models
|
- returns executable models
|
||||||
- does not accept endpoint/auth/deployment overrides
|
- does not accept endpoint/auth/deployment overrides
|
||||||
4. **Model**
|
4. **Language Model**
|
||||||
- model id
|
- model id
|
||||||
- route value
|
- route value
|
||||||
- provider id
|
- provider id
|
||||||
@@ -164,7 +164,7 @@ execution mechanism:
|
|||||||
```ts
|
```ts
|
||||||
type ProviderFacade<APIs, Config> = {
|
type ProviderFacade<APIs, Config> = {
|
||||||
readonly id: ProviderID
|
readonly id: ProviderID
|
||||||
readonly model: (id: string) => Model
|
readonly model: (id: string) => LanguageModel
|
||||||
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
|
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
|
||||||
} & APIs
|
} & APIs
|
||||||
```
|
```
|
||||||
@@ -181,8 +181,8 @@ export const OpenAI = {
|
|||||||
configure: configureOpenAI,
|
configure: configureOpenAI,
|
||||||
} satisfies ProviderFacade<
|
} satisfies ProviderFacade<
|
||||||
{
|
{
|
||||||
responses: (id: string) => Model
|
responses: (id: string) => LanguageModel
|
||||||
chat: (id: string) => Model
|
chat: (id: string) => LanguageModel
|
||||||
},
|
},
|
||||||
OpenAIConfig
|
OpenAIConfig
|
||||||
>
|
>
|
||||||
@@ -360,17 +360,29 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
|||||||
model("gpt-4o", { apiKey, transport: "websocket" })
|
model("gpt-4o", { apiKey, transport: "websocket" })
|
||||||
```
|
```
|
||||||
|
|
||||||
Vertex keeps Gemini and Anthropic Messages as separate package-like entrypoints,
|
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
|
||||||
while sharing project/location resolution and ADC authentication internally:
|
while sharing project/location resolution and ADC authentication internally:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||||
|
|
||||||
model("gemini-3.5-flash", { project, location: "global" })
|
model("gemini-3.5-flash", { project, location: "global" })
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||||
|
|
||||||
|
model("deepseek-ai/deepseek-v3.2-maas", { project, location: "global" })
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||||
|
|
||||||
|
model("xai/grok-4.20-reasoning", { project, location: "global" })
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||||
|
|
||||||
model("claude-sonnet-4-6", { project, location: "global" })
|
model("claude-sonnet-4-6", { project, location: "global" })
|
||||||
```
|
```
|
||||||
@@ -516,7 +528,7 @@ The chosen split is:
|
|||||||
```txt
|
```txt
|
||||||
Route = execution mechanics
|
Route = execution mechanics
|
||||||
Provider facade = configured route group
|
Provider facade = configured route group
|
||||||
Model = selected executable model carrying route value
|
LanguageModel = selected executable model carrying route value
|
||||||
App boundary = explicit durable-config -> typed-provider call
|
App boundary = explicit durable-config -> typed-provider call
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -537,13 +549,13 @@ App boundary = explicit durable-config -> typed-provider call
|
|||||||
entrypoint maps its scoped `transport` setting before constructing the model.
|
entrypoint maps its scoped `transport` setting before constructing the model.
|
||||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||||
client layer with the available transport capabilities.
|
client layer with the available transport capabilities.
|
||||||
- No executable `ModelRef`. The executable handle is `Model`; durable model
|
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
|
||||||
identity stays separate and cannot execute on its own.
|
identity stays separate and cannot execute on its own.
|
||||||
|
|
||||||
## Implementation Todo
|
## Implementation Todo
|
||||||
|
|
||||||
- [x] Replace the current executable `ModelRef` with `Model`.
|
- [x] Replace the current executable `ModelRef` with `LanguageModel`.
|
||||||
- [x] Change `Model.route` to carry a route value, not a `RouteID` string.
|
- [x] Change `LanguageModel.route` to carry a route value, not a `RouteID` string.
|
||||||
- [ ] Keep a separate durable model identity type for persisted/session/catalog
|
- [ ] Keep a separate durable model identity type for persisted/session/catalog
|
||||||
data, likely `{ providerID, modelID }`, and make it clear that it cannot
|
data, likely `{ providerID, modelID }`, and make it clear that it cannot
|
||||||
execute without resolver context.
|
execute without resolver context.
|
||||||
@@ -554,9 +566,9 @@ App boundary = explicit durable-config -> typed-provider call
|
|||||||
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
|
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
|
||||||
configure endpoint/auth through `route.with(...)` or provider facades before
|
configure endpoint/auth through `route.with(...)` or provider facades before
|
||||||
calling `.model(...)`.
|
calling `.model(...)`.
|
||||||
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
|
- [x] Remove request-shaping defaults from `LanguageModel`; selected models now carry only
|
||||||
id, provider, and configured route while defaults live on routes or requests.
|
id, provider, and configured route while defaults live on routes or requests.
|
||||||
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
|
- [x] Rework `LLMClient.stream` / `generate` to read
|
||||||
`request.model.route` directly instead of calling `registeredRoute(...)`.
|
`request.model.route` directly instead of calling `registeredRoute(...)`.
|
||||||
- [x] Remove `Route.make(...)` global registration from the normal execution
|
- [x] Remove `Route.make(...)` global registration from the normal execution
|
||||||
path; keep route ids only as diagnostics/provider API labels.
|
path; keep route ids only as diagnostics/provider API labels.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||||
import { LLM, LLMClient, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
@@ -50,18 +50,6 @@ const request = LLM.request({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// `http` is intentionally not needed for normal calls. This shows the shape for
|
|
||||||
// newly released provider fields before they deserve a typed provider option.
|
|
||||||
const rawOverlayExample = LLM.request({
|
|
||||||
model,
|
|
||||||
prompt: "Show the final HTTP overlay shape.",
|
|
||||||
http: {
|
|
||||||
body: { metadata: { example: "tutorial" } },
|
|
||||||
headers: { "x-opencode-tutorial": "1" },
|
|
||||||
query: { debug: "1" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. `generate` sends the request and collects the event stream into one
|
// 3. `generate` sends the request and collects the event stream into one
|
||||||
// response object. `response.text` is the collected text output.
|
// response object. `response.text` is the collected text output.
|
||||||
const generateOnce = Effect.gen(function* () {
|
const generateOnce = Effect.gen(function* () {
|
||||||
@@ -78,7 +66,10 @@ const streamText = LLM.stream(request).pipe(
|
|||||||
Stream.tap((event) =>
|
Stream.tap((event) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||||
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
if (event.type === "finish")
|
||||||
|
process.stdout.write(
|
||||||
|
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Stream.runDrain,
|
Stream.runDrain,
|
||||||
@@ -113,7 +104,7 @@ const streamWithTools = Effect.gen(function* () {
|
|||||||
|
|
||||||
// A durable agent would persist these messages before starting another
|
// A durable agent would persist these messages before starting another
|
||||||
// raw model turn. This tutorial keeps the boundary visible instead.
|
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||||
const followUp = LLM.updateRequest(request, {
|
const followUp = LLMRequest.update(request, {
|
||||||
messages: [
|
messages: [
|
||||||
...request.messages,
|
...request.messages,
|
||||||
Message.assistant([event]),
|
Message.assistant([event]),
|
||||||
@@ -194,7 +185,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
|||||||
event: Schema.String,
|
event: Schema.String,
|
||||||
initial: () => undefined,
|
initial: () => undefined,
|
||||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||||
onHalt: () => [{ type: "finish", reason: "stop" }],
|
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,33 +210,15 @@ const FakeEcho = {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
|
|
||||||
// body conversion, validation, endpoint, auth, and HTTP construction without
|
|
||||||
// sending anything over the network.
|
|
||||||
const inspectFakeProvider = Effect.gen(function* () {
|
|
||||||
const prepared = yield* LLMClient.prepare(
|
|
||||||
LLM.request({
|
|
||||||
model: FakeEcho.configure().model("tiny-echo"),
|
|
||||||
prompt: "Show me the provider pipeline.",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log("\n== fake provider prepare ==")
|
|
||||||
console.log("route:", prepared.route)
|
|
||||||
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||||
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
|
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||||
// or tool-loop behavior without spending tokens on every example.
|
// tool-loop behavior without spending tokens on every example.
|
||||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
// yield* generateOnce
|
// yield* generateOnce
|
||||||
// yield* inspectFakeProvider
|
|
||||||
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
|
|
||||||
// yield* streamText
|
// yield* streamText
|
||||||
// yield* generateStructuredObject
|
// yield* generateStructuredObject
|
||||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||||
"test": "bun test --timeout 30000 --only-failures",
|
"test": "bun test --timeout 30000 --only-failures",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
|
||||||
"build": "tsc -p tsconfig.build.json"
|
"build": "tsc -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./testing": "./src/testing.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -161,6 +161,18 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
|||||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "minimax",
|
||||||
|
label: "MiniMax",
|
||||||
|
tier: "compatible",
|
||||||
|
note: "Anthropic-compatible Messages text/tool recorded tests",
|
||||||
|
vars: [{ name: "MINIMAX_API_KEY" }],
|
||||||
|
validate: (env) =>
|
||||||
|
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
|
||||||
|
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
|
||||||
|
executeRequest,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mistral",
|
id: "mistral",
|
||||||
label: "Mistral",
|
label: "Mistral",
|
||||||
|
|||||||
@@ -2,32 +2,31 @@
|
|||||||
// the policy designates. Runs once at compile time, before the per-protocol
|
// the policy designates. Runs once at compile time, before the per-protocol
|
||||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||||
//
|
//
|
||||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
// The default `"auto"` shape places breakpoints at the last tool definition,
|
||||||
// one at the last system part, and one at the latest user message. This
|
// the first and last distinct system parts, and the conversation tail. This
|
||||||
// matches what production agent harnesses (LangChain's caching middleware,
|
// exposes reusable tool, base-agent, project, and session prefixes while
|
||||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
// advancing the tail after each tool result keeps the previous cache entry
|
||||||
// latest user message stays put while a single turn explodes into many
|
// within Anthropic's 20-block lookback during long agent turns.
|
||||||
// assistant/tool round-trips, so caching at that boundary lets every
|
|
||||||
// intra-turn API call hit the prefix.
|
|
||||||
//
|
//
|
||||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||||
// this function only fills gaps the caller left empty.
|
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||||
|
|
||||||
const AUTO: CachePolicyObject = {
|
const AUTO: CachePolicyObject = {
|
||||||
tools: true,
|
tools: true,
|
||||||
system: true,
|
system: true,
|
||||||
messages: "latest-user-message",
|
messages: { tail: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const NONE: CachePolicyObject = {}
|
const NONE: CachePolicyObject = {}
|
||||||
|
const BREAKPOINT_CAP = 4
|
||||||
|
|
||||||
// Resolution rules:
|
// Resolution rules:
|
||||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||||
// so a single reuse within 5 minutes already wins.
|
// so a single reuse within 5 minutes already wins.
|
||||||
// - "auto" → tools + system + latest user msg.
|
// - "auto" → tools + first/last system + final message boundary.
|
||||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||||
// - object form → exactly what the caller asked for.
|
// - object form → exactly what the caller asked for.
|
||||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||||
@@ -39,23 +38,37 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
|||||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||||
|
|
||||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||||
|
|
||||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
interface Budget {
|
||||||
|
remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const markLastTool = (
|
||||||
|
tools: ReadonlyArray<ToolDefinition>,
|
||||||
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
|
): ReadonlyArray<ToolDefinition> => {
|
||||||
if (tools.length === 0) return tools
|
if (tools.length === 0) return tools
|
||||||
const last = tools.length - 1
|
const last = tools.length - 1
|
||||||
if (tools[last]!.cache) return tools
|
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||||
|
budget.remaining -= 1
|
||||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||||
}
|
}
|
||||||
|
|
||||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||||
if (system.length === 0) return system
|
if (system.length === 0) return system
|
||||||
const last = system.length - 1
|
let changed = false
|
||||||
if (system[last]!.cache) return system
|
const next = system.map((part, index) => {
|
||||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
|
||||||
|
budget.remaining -= 1
|
||||||
|
changed = true
|
||||||
|
return { ...part, cache: hint }
|
||||||
|
})
|
||||||
|
return changed ? next : system
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||||
@@ -64,14 +77,20 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
|
|||||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||||
// the last content part regardless of type — that's the breakpoint position
|
// the last content part regardless of type — that's the breakpoint position
|
||||||
// in tool-result-only messages too.
|
// in tool-result-only messages too.
|
||||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
const markMessageAt = (
|
||||||
|
messages: ReadonlyArray<Message>,
|
||||||
|
index: number,
|
||||||
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
|
): ReadonlyArray<Message> => {
|
||||||
if (index < 0 || index >= messages.length) return messages
|
if (index < 0 || index >= messages.length) return messages
|
||||||
const target = messages[index]!
|
const target = messages[index]!
|
||||||
if (target.content.length === 0) return messages
|
if (target.content.length === 0) return messages
|
||||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||||
const existing = target.content[markAt]!
|
const existing = target.content[markAt]!
|
||||||
if ("cache" in existing && existing.cache) return messages
|
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
|
||||||
|
budget.remaining -= 1
|
||||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||||
const next = new Message({ ...target, content: nextContent })
|
const next = new Message({ ...target, content: nextContent })
|
||||||
// Single pass over `messages`, substituting the one updated entry. Long
|
// Single pass over `messages`, substituting the one updated entry. Long
|
||||||
@@ -86,25 +105,43 @@ const markMessages = (
|
|||||||
messages: ReadonlyArray<Message>,
|
messages: ReadonlyArray<Message>,
|
||||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||||
hint: CacheHint,
|
hint: CacheHint,
|
||||||
|
budget: Budget,
|
||||||
): ReadonlyArray<Message> => {
|
): ReadonlyArray<Message> => {
|
||||||
if (messages.length === 0) return messages
|
if (messages.length === 0) return messages
|
||||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
if (strategy === "latest-user-message")
|
||||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||||
|
if (strategy === "latest-assistant")
|
||||||
|
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||||
const start = Math.max(0, messages.length - strategy.tail)
|
const start = Math.max(0, messages.length - strategy.tail)
|
||||||
let next = messages
|
let next = messages
|
||||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countHints = (request: LLMRequest) =>
|
||||||
|
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
|
||||||
|
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
|
||||||
|
request.messages.reduce(
|
||||||
|
(count, message) =>
|
||||||
|
count +
|
||||||
|
message.content.reduce(
|
||||||
|
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||||
|
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
||||||
const policy = resolve(request.cache)
|
const policy = resolve(request.cache)
|
||||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||||
|
|
||||||
const hint = makeHint(policy.ttlSeconds)
|
const hint = makeHint(policy.ttlSeconds)
|
||||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
|
||||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
|
||||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
|
||||||
|
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
|
||||||
|
|
||||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||||
return LLMRequest.update(request, { tools, system, messages })
|
return LLMRequest.update(request, { tools, system, messages })
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { RequestExecutor } from "./route/executor"
|
||||||
|
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
|
||||||
|
import type { AIError } from "./schema"
|
||||||
|
|
||||||
|
export type Execute = RequestExecutor.Interface["execute"]
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly generate: <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
) => Effect.Effect<ImageResponse, AIError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||||
|
|
||||||
|
export const generate = <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
): Effect.Effect<ImageResponse, AIError, Service> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const client = yield* Service
|
||||||
|
return yield* client.generate(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const executor = yield* RequestExecutor.Service
|
||||||
|
return Service.of({
|
||||||
|
generate: (request) => request.model.route.generate(request, executor.execute),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ImageClient = {
|
||||||
|
Service,
|
||||||
|
layer,
|
||||||
|
generate,
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { HttpOptions, InvalidRequestReason, AIError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||||
|
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
|
||||||
|
|
||||||
|
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||||
|
readonly id: string
|
||||||
|
readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageOptions = Record<string, unknown>
|
||||||
|
|
||||||
|
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||||
|
declare protected readonly _Options: (options: Options) => Options
|
||||||
|
readonly id: ModelID
|
||||||
|
readonly provider: ProviderID
|
||||||
|
readonly route: ImageRoute<Options>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
|
||||||
|
constructor(input: ImageModel.Input<Options>) {
|
||||||
|
this.id = input.id
|
||||||
|
this.provider = input.provider
|
||||||
|
this.route = input.route
|
||||||
|
this.http = input.http
|
||||||
|
}
|
||||||
|
|
||||||
|
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||||
|
return new ImageModel<Options>({
|
||||||
|
id: ModelID.make(input.id),
|
||||||
|
provider: ProviderID.make(input.provider),
|
||||||
|
route: input.route,
|
||||||
|
http: input.http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ImageModel {
|
||||||
|
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||||
|
readonly id: ModelID
|
||||||
|
readonly provider: ProviderID
|
||||||
|
readonly route: ImageRoute<Options>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||||
|
extends Omit<Input<Options>, "id" | "provider"> {
|
||||||
|
readonly id: string | ModelID
|
||||||
|
readonly provider: string | ProviderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||||
|
expected: "Image.Model",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImageBytesInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("bytes"),
|
||||||
|
data: Schema.Uint8Array,
|
||||||
|
mediaType: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageUrlInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("url"),
|
||||||
|
url: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileIDInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-id"),
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileURIInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-uri"),
|
||||||
|
uri: Schema.String,
|
||||||
|
mediaType: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ImageInputSchema = Schema.Union([
|
||||||
|
ImageBytesInput,
|
||||||
|
ImageUrlInput,
|
||||||
|
ImageFileIDInput,
|
||||||
|
ImageFileURIInput,
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||||
|
|
||||||
|
export const ImageInput = {
|
||||||
|
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||||
|
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||||
|
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||||
|
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||||
|
model: ImageModelSchema,
|
||||||
|
prompt: Schema.String,
|
||||||
|
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||||
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
http: Schema.optional(HttpOptions),
|
||||||
|
}) {
|
||||||
|
declare protected readonly _ImageRequest: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||||
|
readonly model: ImageModel<Options>
|
||||||
|
readonly options?: Options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||||
|
|
||||||
|
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||||
|
ConstructorParameters<typeof ImageRequest>[0],
|
||||||
|
"model" | "options" | "http"
|
||||||
|
> & {
|
||||||
|
readonly model: Model
|
||||||
|
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||||
|
|
||||||
|
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||||
|
mediaType: Schema.String,
|
||||||
|
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||||
|
images: Schema.Array(GeneratedImage),
|
||||||
|
usage: Schema.optional(Usage),
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}) {
|
||||||
|
get image() {
|
||||||
|
return this.images[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function request<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): ImageRequestFor<ImageModelOptions<Model>>
|
||||||
|
export function request(input: ImageRequest): ImageRequest
|
||||||
|
export function request(input: ImageRequest | ImageRequestInput) {
|
||||||
|
if (input instanceof ImageRequest) return input
|
||||||
|
return new ImageRequest({
|
||||||
|
...input,
|
||||||
|
model: input.model as unknown as ImageModel,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generate<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): Effect.Effect<ImageResponse, AIError, Service>
|
||||||
|
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
|
||||||
|
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||||
|
catch: (error) =>
|
||||||
|
new AIError({
|
||||||
|
module: "Image",
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||||
|
}),
|
||||||
|
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Image = {
|
||||||
|
request,
|
||||||
|
generate,
|
||||||
|
} as const
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
export { LLMClient } from "./route/client"
|
export { LLMClient } from "./route/client"
|
||||||
|
export { ImageClient } from "./image-client"
|
||||||
export { Auth } from "./route/auth"
|
export { Auth } from "./route/auth"
|
||||||
export { Provider } from "./provider"
|
export { Provider } from "./provider"
|
||||||
export { ProviderPackage } from "./provider-package"
|
export { ProviderPackage } from "./provider-package"
|
||||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||||
export type {
|
export type {
|
||||||
RouteModelInput,
|
RouteLanguageModelInput,
|
||||||
RouteRoutedModelInput,
|
RouteRoutedLanguageModelInput,
|
||||||
Interface as LLMClientShape,
|
Interface as LLMClientShape,
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./route/client"
|
} from "./route/client"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
|
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
|
||||||
|
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
||||||
|
export { Image } from "./image"
|
||||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||||
export { ToolRuntime } from "./tool-runtime"
|
export { ToolRuntime } from "./tool-runtime"
|
||||||
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
|
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
|
||||||
@@ -29,7 +33,7 @@ export type {
|
|||||||
export * as LLM from "./llm"
|
export * as LLM from "./llm"
|
||||||
export type {
|
export type {
|
||||||
Definition as ProviderDefinition,
|
Definition as ProviderDefinition,
|
||||||
ModelFactory as ProviderModelFactory,
|
LanguageModelFactory as ProviderLanguageModelFactory,
|
||||||
ModelOptions as ProviderModelOptions,
|
LanguageModelOptions as ProviderLanguageModelOptions,
|
||||||
} from "./provider"
|
} from "./provider"
|
||||||
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
|
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
|
||||||
|
|||||||
+32
-38
@@ -1,44 +1,36 @@
|
|||||||
import { Effect, JsonSchema, Schema } from "effect"
|
import { Effect, JsonSchema, Schema } from "effect"
|
||||||
import { LLMClient } from "./route/client"
|
import { LLMClient, Service } from "./route/client"
|
||||||
import {
|
import {
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
InvalidProviderOutputReason,
|
InvalidProviderOutputReason,
|
||||||
LLMError,
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
Message,
|
Message,
|
||||||
type ModelInput as SchemaModelInput,
|
LanguageModel,
|
||||||
SystemPart,
|
SystemPart,
|
||||||
ToolChoice,
|
ToolChoice,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
type ContentPart,
|
type ContentPart,
|
||||||
ToolResultPart,
|
type LanguageModelProviderOptions,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
|
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
|
||||||
|
|
||||||
export type ModelInput = SchemaModelInput
|
|
||||||
|
|
||||||
export type MessageInput = Message.Input
|
|
||||||
|
|
||||||
export type ToolChoiceInput = ToolChoice.Input
|
|
||||||
export type ToolChoiceMode = ToolChoice.Mode
|
|
||||||
|
|
||||||
export type ToolResultInput = Parameters<typeof ToolResultPart.make>[0]
|
|
||||||
|
|
||||||
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
|
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
|
||||||
export type RequestInput = Omit<
|
export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
|
||||||
ConstructorParameters<typeof LLMRequest>[0],
|
ConstructorParameters<typeof LLMRequest>[0],
|
||||||
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
|
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
|
||||||
> & {
|
> & {
|
||||||
|
readonly model: SelectedLanguageModel
|
||||||
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
||||||
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
||||||
readonly messages?: ReadonlyArray<Message | MessageInput>
|
readonly messages?: ReadonlyArray<Message | Message.Input>
|
||||||
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
||||||
readonly toolChoice?: ToolChoiceInput
|
readonly toolChoice?: ToolChoice.Input
|
||||||
readonly generation?: GenerationOptions.Input
|
readonly generation?: GenerationOptions.Input
|
||||||
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
|
readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
|
||||||
readonly http?: HttpOptions.Input
|
readonly http?: HttpOptions.Input
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +38,9 @@ export const generate = LLMClient.generate
|
|||||||
|
|
||||||
export const stream = LLMClient.stream
|
export const stream = LLMClient.stream
|
||||||
|
|
||||||
export const requestInput = (input: LLMRequest): RequestInput => ({
|
export const request = <const SelectedLanguageModel extends LanguageModel>(
|
||||||
...LLMRequest.input(input),
|
input: RequestInput<SelectedLanguageModel>,
|
||||||
})
|
) => {
|
||||||
|
|
||||||
export const request = (input: RequestInput) => {
|
|
||||||
const {
|
const {
|
||||||
system: requestSystem,
|
system: requestSystem,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -74,14 +64,14 @@ export const request = (input: RequestInput) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateRequest = (input: LLMRequest, patch: Partial<RequestInput>) =>
|
|
||||||
request({ ...requestInput(input), ...patch })
|
|
||||||
|
|
||||||
const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
||||||
|
|
||||||
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
||||||
|
|
||||||
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
|
type GenerateObjectBase<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
|
||||||
|
RequestInput<SelectedLanguageModel>,
|
||||||
|
"tools" | "toolChoice"
|
||||||
|
>
|
||||||
|
|
||||||
export class GenerateObjectResponse<T> {
|
export class GenerateObjectResponse<T> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -98,11 +88,15 @@ export class GenerateObjectResponse<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase {
|
export interface GenerateObjectOptions<
|
||||||
|
S extends ToolSchema<any>,
|
||||||
|
SelectedLanguageModel extends LanguageModel = LanguageModel,
|
||||||
|
> extends GenerateObjectBase<SelectedLanguageModel> {
|
||||||
readonly schema: S
|
readonly schema: S
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
|
export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
|
||||||
|
extends GenerateObjectBase<SelectedLanguageModel> {
|
||||||
/** Raw JSON Schema object describing the expected output shape. */
|
/** Raw JSON Schema object describing the expected output shape. */
|
||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
}
|
}
|
||||||
@@ -121,7 +115,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
|||||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||||
)
|
)
|
||||||
if (!call || !LLMEvent.is.toolCall(call))
|
if (!call || !LLMEvent.is.toolCall(call))
|
||||||
return yield* new LLMError({
|
return yield* new AIError({
|
||||||
module: "LLM",
|
module: "LLM",
|
||||||
method: "generateObject",
|
method: "generateObject",
|
||||||
reason: new InvalidProviderOutputReason({
|
reason: new InvalidProviderOutputReason({
|
||||||
@@ -131,7 +125,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
|||||||
const object = yield* tool._decode(call.input).pipe(
|
const object = yield* tool._decode(call.input).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: "LLM",
|
module: "LLM",
|
||||||
method: "generateObject",
|
method: "generateObject",
|
||||||
reason: new InvalidProviderOutputReason({
|
reason: new InvalidProviderOutputReason({
|
||||||
@@ -151,16 +145,16 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
|||||||
* Two input modes:
|
* Two input modes:
|
||||||
*
|
*
|
||||||
* 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`.
|
* 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`.
|
||||||
* Decode failures surface as `LLMError`.
|
* Decode failures surface as `AIError`.
|
||||||
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
|
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
|
||||||
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
|
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
|
||||||
*/
|
*/
|
||||||
export function generateObject<S extends ToolSchema<any>>(
|
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
|
||||||
options: GenerateObjectOptions<S>,
|
options: GenerateObjectOptions<S, SelectedLanguageModel>,
|
||||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
|
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
|
||||||
export function generateObject(
|
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
|
||||||
options: GenerateObjectDynamicOptions,
|
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
|
||||||
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
|
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
|
||||||
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
|
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
|
||||||
if ("schema" in options) {
|
if ("schema" in options) {
|
||||||
const { schema, ...rest } = options
|
const { schema, ...rest } = options
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
LLMError,
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
mergeJsonRecords,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
|
type FinishReasonDetails,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
|
type ProviderOptions,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
@@ -27,9 +30,33 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
|||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
const ADAPTER = "anthropic-messages"
|
const ADAPTER = "anthropic-messages"
|
||||||
|
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||||
export const PATH = "/messages"
|
export const PATH = "/messages"
|
||||||
|
|
||||||
|
export type ThinkingInput =
|
||||||
|
| {
|
||||||
|
readonly type: "adaptive"
|
||||||
|
readonly display?: "summarized" | "omitted"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: "disabled"
|
||||||
|
}
|
||||||
|
| ({ readonly type: "enabled" } & (
|
||||||
|
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||||
|
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||||
|
))
|
||||||
|
|
||||||
|
export interface OptionsInput {
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
readonly thinking?: ThinkingInput
|
||||||
|
readonly effort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderOptionsInput = ProviderOptions & {
|
||||||
|
readonly anthropic?: OptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Request Body Schema
|
// Request Body Schema
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -56,6 +83,17 @@ const AnthropicImageBlock = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
||||||
|
|
||||||
|
const AnthropicDocumentBlock = Schema.Struct({
|
||||||
|
type: Schema.tag("document"),
|
||||||
|
source: Schema.Struct({
|
||||||
|
type: Schema.tag("base64"),
|
||||||
|
media_type: Schema.Literal("application/pdf"),
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
|
})
|
||||||
|
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
|
||||||
|
|
||||||
const AnthropicThinkingBlock = Schema.Struct({
|
const AnthropicThinkingBlock = Schema.Struct({
|
||||||
type: Schema.tag("thinking"),
|
type: Schema.tag("thinking"),
|
||||||
thinking: Schema.String,
|
thinking: Schema.String,
|
||||||
@@ -63,6 +101,15 @@ const AnthropicThinkingBlock = Schema.Struct({
|
|||||||
cache_control: Schema.optional(AnthropicCacheControl),
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Safety-filtered thinking arrives as an opaque encrypted `data` payload with
|
||||||
|
// no visible text. It must round-trip verbatim so multi-turn thinking + tool
|
||||||
|
// use conversations keep their reasoning continuity.
|
||||||
|
const AnthropicRedactedThinkingBlock = Schema.Struct({
|
||||||
|
type: Schema.tag("redacted_thinking"),
|
||||||
|
data: Schema.String,
|
||||||
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
|
})
|
||||||
|
|
||||||
const AnthropicToolUseBlock = Schema.Struct({
|
const AnthropicToolUseBlock = Schema.Struct({
|
||||||
type: Schema.tag("tool_use"),
|
type: Schema.tag("tool_use"),
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
@@ -101,13 +148,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
|
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
|
||||||
|
|
||||||
// Anthropic accepts either a plain string or an ordered array of text/image
|
// Anthropic accepts either a plain string or an ordered array of text, image, and
|
||||||
// blocks inside `tool_result.content`. The array form is required when a tool
|
// document blocks inside `tool_result.content`. The array form keeps media as native
|
||||||
// returns image bytes (screenshot, image search, etc.) so they can be passed
|
// model input instead of JSON-stringifying base64 into prompt text.
|
||||||
// to the model as proper image inputs instead of being JSON-stringified into
|
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
|
||||||
// the prompt — which silently inflates context by megabytes and can push the
|
|
||||||
// conversation over the model's token limit.
|
|
||||||
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
|
|
||||||
|
|
||||||
const AnthropicToolResultBlock = Schema.Struct({
|
const AnthropicToolResultBlock = Schema.Struct({
|
||||||
type: Schema.tag("tool_result"),
|
type: Schema.tag("tool_result"),
|
||||||
@@ -117,11 +161,17 @@ const AnthropicToolResultBlock = Schema.Struct({
|
|||||||
cache_control: Schema.optional(AnthropicCacheControl),
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
})
|
})
|
||||||
|
|
||||||
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
|
const AnthropicUserBlock = Schema.Union([
|
||||||
|
AnthropicTextBlock,
|
||||||
|
AnthropicImageBlock,
|
||||||
|
AnthropicDocumentBlock,
|
||||||
|
AnthropicToolResultBlock,
|
||||||
|
])
|
||||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||||
const AnthropicAssistantBlock = Schema.Union([
|
const AnthropicAssistantBlock = Schema.Union([
|
||||||
AnthropicTextBlock,
|
AnthropicTextBlock,
|
||||||
AnthropicThinkingBlock,
|
AnthropicThinkingBlock,
|
||||||
|
AnthropicRedactedThinkingBlock,
|
||||||
AnthropicToolUseBlock,
|
AnthropicToolUseBlock,
|
||||||
AnthropicServerToolUseBlock,
|
AnthropicServerToolUseBlock,
|
||||||
AnthropicServerToolResultBlock,
|
AnthropicServerToolResultBlock,
|
||||||
@@ -145,7 +195,7 @@ const AnthropicTool = Schema.Struct({
|
|||||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||||
|
|
||||||
const AnthropicToolChoice = Schema.Union([
|
const AnthropicToolChoice = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
|
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
|
||||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -185,12 +235,25 @@ const AnthropicBodyFields = {
|
|||||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||||
|
|
||||||
const AnthropicUsage = Schema.Struct({
|
const AnthropicUsage = Schema.StructWithRest(
|
||||||
input_tokens: Schema.optional(Schema.Number),
|
Schema.Struct({
|
||||||
output_tokens: Schema.optional(Schema.Number),
|
input_tokens: Schema.optional(Schema.Number),
|
||||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||||
})
|
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||||
|
server_tool_use: optionalNull(
|
||||||
|
Schema.StructWithRest(Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), [
|
||||||
|
Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
output_tokens_details: optionalNull(
|
||||||
|
Schema.StructWithRest(Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), [
|
||||||
|
Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||||
|
|
||||||
const AnthropicStreamBlock = Schema.Struct({
|
const AnthropicStreamBlock = Schema.Struct({
|
||||||
@@ -200,6 +263,9 @@ const AnthropicStreamBlock = Schema.Struct({
|
|||||||
text: Schema.optional(Schema.String),
|
text: Schema.optional(Schema.String),
|
||||||
thinking: Schema.optional(Schema.String),
|
thinking: Schema.optional(Schema.String),
|
||||||
signature: Schema.optional(Schema.String),
|
signature: Schema.optional(Schema.String),
|
||||||
|
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||||
|
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||||
|
data: Schema.optional(Schema.String),
|
||||||
input: Schema.optional(Schema.Unknown),
|
input: Schema.optional(Schema.Unknown),
|
||||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||||
// delta) with the structured payload in `content` and the originating
|
// delta) with the structured payload in `content` and the originating
|
||||||
@@ -237,7 +303,12 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
|||||||
|
|
||||||
interface ParserState {
|
interface ParserState {
|
||||||
readonly tools: ToolStream.State<number>
|
readonly tools: ToolStream.State<number>
|
||||||
|
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
|
readonly pendingFinish?: {
|
||||||
|
readonly reason: FinishReasonDetails
|
||||||
|
readonly providerMetadata?: ProviderMetadata
|
||||||
|
}
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +344,12 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
|||||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||||
|
const anthropic = metadata?.anthropic
|
||||||
|
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||||
|
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
@@ -283,7 +360,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
|
|||||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||||
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
||||||
auto: () => ({ type: "auto" as const }),
|
auto: () => ({ type: "auto" as const }),
|
||||||
none: () => undefined,
|
none: () => ({ type: "none" as const }),
|
||||||
required: () => ({ type: "any" as const }),
|
required: () => ({ type: "any" as const }),
|
||||||
tool: (name) => ({ type: "tool" as const, name }),
|
tool: (name) => ({ type: "tool" as const, name }),
|
||||||
})
|
})
|
||||||
@@ -316,15 +393,23 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
|||||||
const wireType = serverToolResultType(part.name)
|
const wireType = serverToolResultType(part.name)
|
||||||
if (!wireType)
|
if (!wireType)
|
||||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||||
|
// histories constructed directly from provider events.
|
||||||
|
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||||
|
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
|
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||||
const media = yield* ProviderShared.validateMedia(
|
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
|
||||||
"Anthropic Messages",
|
if (media.mime === "application/pdf")
|
||||||
part,
|
return {
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
type: "document" as const,
|
||||||
)
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: "application/pdf" as const,
|
||||||
|
data: media.base64,
|
||||||
|
},
|
||||||
|
} satisfies AnthropicDocumentBlock
|
||||||
return {
|
return {
|
||||||
type: "image" as const,
|
type: "image" as const,
|
||||||
source: {
|
source: {
|
||||||
@@ -335,25 +420,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
|
|||||||
} satisfies AnthropicImageBlock
|
} satisfies AnthropicImageBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
// Tool results may carry structured text/images. Keep media as provider-native
|
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||||
item: ToolContent,
|
item: Tool.Content,
|
||||||
) {
|
) {
|
||||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||||
const media = yield* ProviderShared.validateToolFile(
|
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||||
"Anthropic Messages",
|
|
||||||
item,
|
|
||||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
type: "image" as const,
|
|
||||||
source: {
|
|
||||||
type: "base64" as const,
|
|
||||||
media_type: media.mime,
|
|
||||||
data: media.base64,
|
|
||||||
},
|
|
||||||
} satisfies AnthropicImageBlock
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
|
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||||
@@ -361,7 +434,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
|||||||
// with existing cassettes and provider expectations.
|
// with existing cassettes and provider expectations.
|
||||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -445,7 +518,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "media") {
|
if (part.type === "media") {
|
||||||
content.push(yield* lowerImage(part))
|
content.push(yield* lowerMedia(part))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||||
@@ -462,11 +535,16 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
content.push({
|
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
|
||||||
type: "thinking",
|
// thinking; only signature-less parts carrying redactedData
|
||||||
thinking: part.text,
|
// round-trip as opaque redacted_thinking blocks.
|
||||||
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
|
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||||
})
|
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||||
|
if (signature === undefined && redactedData !== undefined) {
|
||||||
|
content.push({ type: "redacted_thinking", data: redactedData })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content.push({ type: "thinking", thinking: part.text, signature })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
@@ -503,39 +581,39 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
|
|
||||||
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
|
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||||
|
const input = request.providerOptions?.anthropic
|
||||||
|
return {
|
||||||
|
thinking: yield* resolveThinking(input?.thinking),
|
||||||
|
effort: typeof input?.effort === "string" ? input.effort : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||||
const thinking = anthropicOptions(request)?.thinking
|
if (!ProviderShared.isRecord(input)) return undefined
|
||||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
if (input.type === "adaptive") {
|
||||||
if (thinking.type === "adaptive") {
|
|
||||||
const display =
|
const display =
|
||||||
thinking.display === "summarized"
|
input.display === "summarized"
|
||||||
? ("summarized" as const)
|
? ("summarized" as const)
|
||||||
: thinking.display === "omitted"
|
: input.display === "omitted"
|
||||||
? ("omitted" as const)
|
? ("omitted" as const)
|
||||||
: undefined
|
: undefined
|
||||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||||
}
|
}
|
||||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
if (input.type === "disabled") return { type: "disabled" as const }
|
||||||
if (thinking.type !== "enabled") return undefined
|
if (input.type !== "enabled") return undefined
|
||||||
const budget =
|
const budget =
|
||||||
typeof thinking.budgetTokens === "number"
|
typeof input.budgetTokens === "number"
|
||||||
? thinking.budgetTokens
|
? input.budgetTokens
|
||||||
: typeof thinking.budget_tokens === "number"
|
: typeof input.budget_tokens === "number"
|
||||||
? thinking.budget_tokens
|
? input.budget_tokens
|
||||||
: undefined
|
: undefined
|
||||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
if (budget === undefined)
|
||||||
|
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||||
return { type: "enabled" as const, budget_tokens: budget }
|
return { type: "enabled" as const, budget_tokens: budget }
|
||||||
})
|
})
|
||||||
|
|
||||||
const outputConfig = (request: LLMRequest) => {
|
|
||||||
const effort = anthropicOptions(request)?.effort
|
|
||||||
return typeof effort === "string" ? { effort } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||||
@@ -544,7 +622,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||||
const tools =
|
const tools =
|
||||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
request.tools.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) =>
|
: request.tools.map((tool) =>
|
||||||
lowerTool(
|
lowerTool(
|
||||||
@@ -553,6 +631,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||||
|
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||||
const system =
|
const system =
|
||||||
request.system.length === 0
|
request.system.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
@@ -567,6 +647,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const options = yield* resolveOptions(request)
|
||||||
return {
|
return {
|
||||||
model: request.model.id,
|
model: request.model.id,
|
||||||
system,
|
system,
|
||||||
@@ -579,8 +660,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
top_k: generation?.topK,
|
top_k: generation?.topK,
|
||||||
stop_sequences: generation?.stop,
|
stop_sequences: generation?.stop,
|
||||||
thinking: yield* lowerThinking(request),
|
thinking: options.thinking,
|
||||||
output_config: outputConfig(request),
|
output_config: options.effort === undefined ? undefined : { effort: options.effort },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -589,7 +670,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||||
if (reason === "max_tokens") return "length"
|
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||||
if (reason === "tool_use") return "tool-calls"
|
if (reason === "tool_use") return "tool-calls"
|
||||||
if (reason === "refusal") return "content-filter"
|
if (reason === "refusal") return "content-filter"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -599,9 +680,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||||
// cache reads and writes as separate fields. We sum them to derive the
|
// cache reads and writes as separate fields. We sum them to derive the
|
||||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
// thinking tokens are included in `output_tokens`; newer responses also
|
||||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||||
// `outputTokens` carries the combined total.
|
|
||||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const nonCached = usage.input_tokens
|
const nonCached = usage.input_tokens
|
||||||
@@ -614,6 +694,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
|||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cacheRead,
|
cacheReadInputTokens: cacheRead,
|
||||||
cacheWriteInputTokens: cacheWrite,
|
cacheWriteInputTokens: cacheWrite,
|
||||||
|
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||||
providerMetadata: { anthropic: usage },
|
providerMetadata: { anthropic: usage },
|
||||||
})
|
})
|
||||||
@@ -632,18 +713,17 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
|||||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||||
|
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
nonCachedInputTokens,
|
nonCachedInputTokens,
|
||||||
cacheReadInputTokens,
|
cacheReadInputTokens,
|
||||||
cacheWriteInputTokens,
|
cacheWriteInputTokens,
|
||||||
|
reasoningTokens,
|
||||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
anthropic: {
|
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||||
...left.providerMetadata?.["anthropic"],
|
|
||||||
...right.providerMetadata?.["anthropic"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -673,7 +753,9 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
|||||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
// The complete payload is irreducible provider replay state: subsequent
|
||||||
|
// stateless requests must round-trip the typed result block verbatim.
|
||||||
|
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,27 +782,69 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
tools: ToolStream.start(state.tools, event.index, {
|
tools: ToolStream.start(state.tools, event.index, {
|
||||||
id: block.id ?? String(event.index),
|
id: block.id ?? String(event.index),
|
||||||
name: block.name ?? "",
|
name: block.name ?? "",
|
||||||
|
input:
|
||||||
|
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||||
|
? ProviderShared.encodeJson(block.input)
|
||||||
|
: undefined,
|
||||||
providerExecuted: block.type === "server_tool_use",
|
providerExecuted: block.type === "server_tool_use",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
[
|
||||||
|
...events,
|
||||||
|
LLMEvent.toolInputStart({
|
||||||
|
id: block.id ?? String(event.index),
|
||||||
|
name: block.name ?? "",
|
||||||
|
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text !== undefined) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const id = `text-${event.index ?? 0}`
|
||||||
|
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "thinking" && block.thinking) {
|
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const id = `reasoning-${event.index ?? 0}`
|
||||||
|
const providerMetadata =
|
||||||
|
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||||
|
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
lifecycle: block.thinking
|
||||||
|
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||||
|
: lifecycle,
|
||||||
|
reasoningSignatures:
|
||||||
|
event.index === undefined || block.signature === undefined
|
||||||
|
? state.reasoningSignatures
|
||||||
|
: { ...state.reasoningSignatures, [event.index]: block.signature },
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||||
|
// payload as `redactedData` metadata (same model as Vercel's
|
||||||
|
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||||
|
if (block.type === "redacted_thinking" && block.data !== undefined) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
lifecycle: Lifecycle.reasoningStart(
|
||||||
|
state.lifecycle,
|
||||||
|
events,
|
||||||
|
`reasoning-${event.index ?? 0}`,
|
||||||
|
anthropicMetadata({ redactedData: block.data }),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
@@ -758,18 +882,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (delta?.type === "signature_delta" && delta.signature) {
|
if (delta?.type === "signature_delta" && delta.signature) {
|
||||||
const events: LLMEvent[] = []
|
const index = event.index ?? 0
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningEnd(
|
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
|
||||||
state.lifecycle,
|
|
||||||
events,
|
|
||||||
`reasoning-${event.index ?? 0}`,
|
|
||||||
anthropicMetadata({ signature: delta.signature }),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
events,
|
NO_EVENTS,
|
||||||
] satisfies StepResult
|
] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,28 +919,53 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
|||||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const resultEvents = result.events ?? []
|
const resultEvents = result.events ?? []
|
||||||
|
const signature = state.reasoningSignatures[event.index]
|
||||||
const lifecycle = resultEvents.length
|
const lifecycle = resultEvents.length
|
||||||
? Lifecycle.stepStart(state.lifecycle, events)
|
? Lifecycle.stepStart(state.lifecycle, events)
|
||||||
: Lifecycle.reasoningEnd(
|
: Lifecycle.reasoningEnd(
|
||||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||||
events,
|
events,
|
||||||
`reasoning-${event.index}`,
|
`reasoning-${event.index}`,
|
||||||
|
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||||
)
|
)
|
||||||
events.push(...resultEvents)
|
events.push(...resultEvents)
|
||||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||||
|
delete reasoningSignatures[event.index]
|
||||||
|
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||||
})
|
})
|
||||||
|
|
||||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
usage,
|
||||||
|
pendingFinish: {
|
||||||
|
reason: {
|
||||||
|
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||||
|
raw: event.delta?.stop_reason ?? undefined,
|
||||||
|
},
|
||||||
|
providerMetadata:
|
||||||
|
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||||
|
? undefined
|
||||||
|
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NO_EVENTS,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMessageStop = (state: ParserState): StepResult => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason: mapFinishReason(event.delta?.stop_reason),
|
reason: state.pendingFinish?.reason ?? {
|
||||||
usage,
|
normalized: "unknown",
|
||||||
providerMetadata: event.delta?.stop_sequence
|
raw: undefined,
|
||||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
},
|
||||||
: undefined,
|
usage: state.usage,
|
||||||
|
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||||
})
|
})
|
||||||
return [{ ...state, lifecycle, usage }, events]
|
return [{ ...state, lifecycle }, events]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||||
@@ -834,7 +978,7 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onError = (event: AnthropicEvent) =>
|
const onError = (event: AnthropicEvent) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: ADAPTER,
|
module: ADAPTER,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||||
@@ -846,6 +990,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
|||||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||||
|
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||||
if (event.type === "error") return onError(event)
|
if (event.type === "error") return onError(event)
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
@@ -866,7 +1011,11 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(AnthropicEvent),
|
event: Protocol.jsonEvent(AnthropicEvent),
|
||||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
initial: () => ({
|
||||||
|
tools: ToolStream.empty<number>(),
|
||||||
|
reasoningSignatures: {},
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
}),
|
||||||
step,
|
step,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { Route } from "../route/client"
|
|||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
LLMError,
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
|
type FinishReasonDetails,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ModelToolSchemaCompatibility,
|
type LanguageModelToolSchemaCompatibility,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ReasoningPart,
|
type ReasoningPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
@@ -52,6 +53,7 @@ const BedrockToolResultContentItem = Schema.Union([
|
|||||||
Schema.Struct({ text: Schema.String }),
|
Schema.Struct({ text: Schema.String }),
|
||||||
Schema.Struct({ json: Schema.Unknown }),
|
Schema.Struct({ json: Schema.Unknown }),
|
||||||
BedrockMedia.ImageBlock,
|
BedrockMedia.ImageBlock,
|
||||||
|
BedrockMedia.DocumentBlock,
|
||||||
])
|
])
|
||||||
|
|
||||||
const BedrockToolResultBlock = Schema.Struct({
|
const BedrockToolResultBlock = Schema.Struct({
|
||||||
@@ -64,14 +66,15 @@ const BedrockToolResultBlock = Schema.Struct({
|
|||||||
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
|
type BedrockToolResultBlock = Schema.Schema.Type<typeof BedrockToolResultBlock>
|
||||||
|
|
||||||
const BedrockReasoningBlock = Schema.Struct({
|
const BedrockReasoningBlock = Schema.Struct({
|
||||||
reasoningContent: Schema.Struct({
|
reasoningContent: Schema.Union([
|
||||||
reasoningText: Schema.optional(
|
Schema.Struct({
|
||||||
Schema.Struct({
|
reasoningText: Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
signature: Schema.optional(Schema.String),
|
signature: Schema.optional(Schema.String),
|
||||||
}),
|
}),
|
||||||
),
|
}),
|
||||||
}),
|
Schema.Struct({ redactedContent: Schema.String }),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const BedrockUserBlock = Schema.Union([
|
const BedrockUserBlock = Schema.Union([
|
||||||
@@ -152,6 +155,12 @@ const BedrockUsageSchema = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
|
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
|
||||||
|
|
||||||
|
const BedrockStreamException = Schema.Struct({
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
originalMessage: Schema.optional(Schema.String),
|
||||||
|
originalStatusCode: Schema.optional(Schema.Number),
|
||||||
|
})
|
||||||
|
|
||||||
// Streaming event shape — the AWS event stream wraps each JSON payload by its
|
// Streaming event shape — the AWS event stream wraps each JSON payload by its
|
||||||
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
|
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
|
||||||
// reconstruct that wrapping in `decodeFrames` below so the event schema can
|
// reconstruct that wrapping in `decodeFrames` below so the event schema can
|
||||||
@@ -179,6 +188,11 @@ const BedrockEvent = Schema.Struct({
|
|||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
text: Schema.optional(Schema.String),
|
text: Schema.optional(Schema.String),
|
||||||
signature: Schema.optional(Schema.String),
|
signature: Schema.optional(Schema.String),
|
||||||
|
// Blob fields in Bedrock's JSON event stream are base64 strings.
|
||||||
|
redactedContent: Schema.optional(Schema.String),
|
||||||
|
// Vercel's Bedrock provider exposes the same delta under
|
||||||
|
// Anthropic's shorter `data` spelling.
|
||||||
|
data: Schema.optional(Schema.String),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -198,11 +212,11 @@ const BedrockEvent = Schema.Struct({
|
|||||||
metrics: Schema.optional(Schema.Unknown),
|
metrics: Schema.optional(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
internalServerException: Schema.optional(BedrockStreamException),
|
||||||
modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||||
validationException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
validationException: Schema.optional(BedrockStreamException),
|
||||||
throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
throttlingException: Schema.optional(BedrockStreamException),
|
||||||
serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })),
|
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||||
})
|
})
|
||||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||||
|
|
||||||
@@ -218,7 +232,7 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
|
|||||||
})
|
})
|
||||||
|
|
||||||
const lowerTools = (
|
const lowerTools = (
|
||||||
compatibility: ModelToolSchemaCompatibility | undefined,
|
compatibility: LanguageModelToolSchemaCompatibility | undefined,
|
||||||
breakpoints: BedrockCache.Breakpoints,
|
breakpoints: BedrockCache.Breakpoints,
|
||||||
tools: ReadonlyArray<ToolDefinition>,
|
tools: ReadonlyArray<ToolDefinition>,
|
||||||
): BedrockTool[] => {
|
): BedrockTool[] => {
|
||||||
@@ -258,6 +272,11 @@ const reasoningSignature = (part: ReasoningPart) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||||
|
const bedrock = part.providerMetadata?.bedrock
|
||||||
|
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||||
toolUse: {
|
toolUse: {
|
||||||
toolUseId: part.id,
|
toolUseId: part.id,
|
||||||
@@ -283,8 +302,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
|||||||
data: item.uri,
|
data: item.uri,
|
||||||
filename: item.name,
|
filename: item.name,
|
||||||
})
|
})
|
||||||
if (!("image" in media))
|
|
||||||
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
|
|
||||||
content.push(media)
|
content.push(media)
|
||||||
}
|
}
|
||||||
return content
|
return content
|
||||||
@@ -349,11 +366,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
content.push({
|
const signature = reasoningSignature(part)
|
||||||
reasoningContent: {
|
const redactedData = reasoningRedactedData(part)
|
||||||
reasoningText: { text: part.text, signature: reasoningSignature(part) },
|
if (signature === undefined && redactedData !== undefined) {
|
||||||
},
|
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||||
})
|
continue
|
||||||
|
}
|
||||||
|
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
@@ -393,8 +412,13 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
|||||||
// tools → system → messages order to favour the highest-impact prefixes.
|
// tools → system → messages order to favour the highest-impact prefixes.
|
||||||
const breakpoints = BedrockCache.breakpoints()
|
const breakpoints = BedrockCache.breakpoints()
|
||||||
const toolConfig =
|
const toolConfig =
|
||||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
request.tools.length > 0
|
||||||
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
|
? {
|
||||||
|
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
||||||
|
// Converse has no native "none". Keep definitions stable for prompt
|
||||||
|
// caching and omit only the unsupported choice.
|
||||||
|
toolChoice,
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||||
const messages = yield* lowerMessages(request, breakpoints)
|
const messages = yield* lowerMessages(request, breakpoints)
|
||||||
@@ -431,27 +455,29 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
const mapFinishReason = (reason: string): FinishReason => {
|
const mapFinishReason = (reason: string): FinishReason => {
|
||||||
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
||||||
if (reason === "max_tokens") return "length"
|
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||||
if (reason === "tool_use") return "tool-calls"
|
if (reason === "tool_use") return "tool-calls"
|
||||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||||
|
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
// AWS reports inputTokens separately from cache reads and writes.
|
||||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||||
// the total through and derive the non-cached breakdown. Bedrock does
|
|
||||||
// not break reasoning out of `outputTokens` for any current model.
|
|
||||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
const inputTokens = ProviderShared.sumTokens(
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
usage.inputTokens,
|
||||||
|
usage.cacheReadInputTokens,
|
||||||
|
usage.cacheWriteInputTokens,
|
||||||
|
)
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.inputTokens,
|
inputTokens,
|
||||||
outputTokens: usage.outputTokens,
|
outputTokens: usage.outputTokens,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: usage.inputTokens,
|
||||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||||
providerMetadata: { bedrock: usage },
|
providerMetadata: { bedrock: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -461,7 +487,7 @@ interface ParserState {
|
|||||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||||
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||||
readonly hasToolCalls: boolean
|
readonly hasToolCalls: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||||
@@ -512,12 +538,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
const index = event.contentBlockDelta.contentBlockIndex
|
const index = event.contentBlockDelta.contentBlockIndex
|
||||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||||
|
const providerMetadata = reasoning.signature
|
||||||
|
? bedrockMetadata({ signature: reasoning.signature })
|
||||||
|
: redactedData !== undefined
|
||||||
|
? bedrockMetadata({ redactedData })
|
||||||
|
: undefined
|
||||||
|
const lifecycle =
|
||||||
|
reasoning.text !== undefined || providerMetadata !== undefined
|
||||||
|
? Lifecycle.reasoningDelta(
|
||||||
|
state.lifecycle,
|
||||||
|
events,
|
||||||
|
`reasoning-${index}`,
|
||||||
|
reasoning.text ?? "",
|
||||||
|
providerMetadata,
|
||||||
|
)
|
||||||
|
: state.lifecycle
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: reasoning.text
|
lifecycle,
|
||||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text)
|
|
||||||
: state.lifecycle,
|
|
||||||
reasoningSignatures: reasoning.signature
|
reasoningSignatures: reasoning.signature
|
||||||
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
? { ...state.reasoningSignatures, [index]: reasoning.signature }
|
||||||
: state.reasoningSignatures,
|
: state.reasoningSignatures,
|
||||||
@@ -561,7 +601,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
hasToolCalls:
|
||||||
|
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasToolCalls,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
tools: result.tools,
|
tools: result.tools,
|
||||||
reasoningSignatures: Object.fromEntries(
|
reasoningSignatures: Object.fromEntries(
|
||||||
@@ -576,15 +618,30 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
|
pendingFinish: {
|
||||||
|
reason: {
|
||||||
|
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||||
|
raw: event.messageStop.stopReason,
|
||||||
|
},
|
||||||
|
usage: state.pendingFinish?.usage,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
] as const
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.metadata) {
|
if (event.metadata) {
|
||||||
const usage = mapUsage(event.metadata.usage)
|
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
pendingFinish: {
|
||||||
|
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||||
|
usage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const exception = (
|
const exception = (
|
||||||
@@ -597,11 +654,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
] as const
|
] as const
|
||||||
).find((entry) => entry[1] !== undefined)
|
).find((entry) => entry[1] !== undefined)
|
||||||
if (exception) {
|
if (exception) {
|
||||||
return yield* new LLMError({
|
return yield* new AIError({
|
||||||
module: ADAPTER,
|
module: ADAPTER,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({
|
reason: classifyProviderFailure({
|
||||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
|
||||||
code: exception[0],
|
code: exception[0],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -617,8 +674,13 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
|||||||
? (() => {
|
? (() => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
Lifecycle.finish(state.lifecycle, events, {
|
Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason:
|
reason: {
|
||||||
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
...state.pendingFinish.reason,
|
||||||
|
normalized:
|
||||||
|
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
||||||
|
? "tool-calls"
|
||||||
|
: state.pendingFinish.reason.normalized,
|
||||||
|
},
|
||||||
usage: state.pendingFinish.usage,
|
usage: state.pendingFinish.usage,
|
||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -53,8 +53,22 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
|||||||
})
|
})
|
||||||
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
||||||
|
|
||||||
if (decoded.headers[":message-type"]?.value !== "event") continue
|
const messageType = decoded.headers[":message-type"]?.value
|
||||||
const eventType = decoded.headers[":event-type"]?.value
|
if (messageType === "error") {
|
||||||
|
const code = decoded.headers[":error-code"]?.value
|
||||||
|
const message = decoded.headers[":error-message"]?.value
|
||||||
|
return yield* ProviderShared.eventError(
|
||||||
|
route,
|
||||||
|
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
|
||||||
|
"Bedrock Converse event-stream error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const eventType =
|
||||||
|
messageType === "event"
|
||||||
|
? decoded.headers[":event-type"]?.value
|
||||||
|
: messageType === "exception"
|
||||||
|
? decoded.headers[":exception-type"]?.value
|
||||||
|
: undefined
|
||||||
if (typeof eventType !== "string") continue
|
if (typeof eventType !== "string") continue
|
||||||
const payload = utf8.decode(decoded.body)
|
const payload = utf8.decode(decoded.body)
|
||||||
if (!payload) continue
|
if (!payload) continue
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
@@ -11,11 +12,11 @@ import {
|
|||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
|
type ProviderOptions,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||||
@@ -26,6 +27,39 @@ const ADAPTER = "gemini"
|
|||||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
export interface OptionsInput {
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
readonly cachedContent?: string
|
||||||
|
readonly safetySettings?: ReadonlyArray<{
|
||||||
|
readonly category:
|
||||||
|
| "HARM_CATEGORY_UNSPECIFIED"
|
||||||
|
| "HARM_CATEGORY_HATE_SPEECH"
|
||||||
|
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||||
|
| "HARM_CATEGORY_HARASSMENT"
|
||||||
|
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||||
|
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||||
|
| (string & {})
|
||||||
|
readonly threshold:
|
||||||
|
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||||
|
| "BLOCK_LOW_AND_ABOVE"
|
||||||
|
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||||
|
| "BLOCK_ONLY_HIGH"
|
||||||
|
| "BLOCK_NONE"
|
||||||
|
| "OFF"
|
||||||
|
| (string & {})
|
||||||
|
}>
|
||||||
|
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||||
|
readonly thinkingConfig?: {
|
||||||
|
readonly thinkingBudget?: number
|
||||||
|
readonly includeThoughts?: boolean
|
||||||
|
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderOptionsInput = ProviderOptions & {
|
||||||
|
readonly gemini?: OptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Request Body Schema
|
// Request Body Schema
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -41,9 +75,11 @@ const GeminiInlineDataPart = Schema.Struct({
|
|||||||
data: Schema.String,
|
data: Schema.String,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||||
|
|
||||||
const GeminiFunctionCallPart = Schema.Struct({
|
const GeminiFunctionCallPart = Schema.Struct({
|
||||||
functionCall: Schema.Struct({
|
functionCall: Schema.Struct({
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.Unknown,
|
||||||
}),
|
}),
|
||||||
@@ -52,8 +88,10 @@ const GeminiFunctionCallPart = Schema.Struct({
|
|||||||
|
|
||||||
const GeminiFunctionResponsePart = Schema.Struct({
|
const GeminiFunctionResponsePart = Schema.Struct({
|
||||||
functionResponse: Schema.Struct({
|
functionResponse: Schema.Struct({
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
response: Schema.Unknown,
|
response: Schema.Unknown,
|
||||||
|
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
|
|||||||
const GeminiThinkingConfig = Schema.Struct({
|
const GeminiThinkingConfig = Schema.Struct({
|
||||||
thinkingBudget: Schema.optional(Schema.Number),
|
thinkingBudget: Schema.optional(Schema.Number),
|
||||||
includeThoughts: Schema.optional(Schema.Boolean),
|
includeThoughts: Schema.optional(Schema.Boolean),
|
||||||
|
thinkingLevel: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
const GeminiSafetySetting = Schema.Struct({
|
||||||
|
category: Schema.String,
|
||||||
|
threshold: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const GeminiGenerationConfig = Schema.Struct({
|
const GeminiGenerationConfig = Schema.Struct({
|
||||||
@@ -106,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const GeminiBodyFields = {
|
const GeminiBodyFields = {
|
||||||
|
cachedContent: Schema.optional(Schema.String),
|
||||||
contents: Schema.Array(GeminiContent),
|
contents: Schema.Array(GeminiContent),
|
||||||
|
safetySettings: optionalArray(GeminiSafetySetting),
|
||||||
|
serviceTier: Schema.optional(Schema.String),
|
||||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||||
tools: optionalArray(GeminiTool),
|
tools: optionalArray(GeminiTool),
|
||||||
toolConfig: Schema.optional(GeminiToolConfig),
|
toolConfig: Schema.optional(GeminiToolConfig),
|
||||||
@@ -197,8 +244,15 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||||
|
const google = providerMetadata?.google
|
||||||
|
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||||
|
? google.functionCallId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart) => ({
|
const lowerToolCall = (part: ToolCallPart) => ({
|
||||||
functionCall: { name: part.name, args: part.input },
|
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,6 +309,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
if (part.result.type !== "content") {
|
if (part.result.type !== "content") {
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
|
id: functionCallId(part.providerMetadata),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
@@ -264,22 +319,25 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||||
|
const media: GeminiInlineDataPart[] = []
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === "text") continue
|
||||||
|
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
||||||
|
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||||
|
}
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
|
id: functionCallId(part.providerMetadata),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
content: text.join("\n"),
|
content: text.join("\n"),
|
||||||
},
|
},
|
||||||
|
parts: media.length > 0 ? media : undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
for (const item of content) {
|
|
||||||
if (item.type === "text") continue
|
|
||||||
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
|
||||||
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
contents.push({ role: "user", parts })
|
contents.push({ role: "user", parts })
|
||||||
}
|
}
|
||||||
@@ -287,21 +345,43 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
return contents
|
return contents
|
||||||
})
|
})
|
||||||
|
|
||||||
const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
|
const resolveOptions = (request: LLMRequest) => {
|
||||||
|
const input = request.providerOptions?.gemini
|
||||||
const thinkingConfig = (request: LLMRequest) => {
|
const value = input?.thinkingConfig
|
||||||
const value = geminiOptions(request)?.thinkingConfig
|
const thinkingConfig = {
|
||||||
if (!ProviderShared.isRecord(value)) return undefined
|
thinkingBudget:
|
||||||
const result = {
|
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
includeThoughts:
|
||||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||||
|
? value.includeThoughts
|
||||||
|
: ProviderShared.isRecord(value)
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
thinkingLevel:
|
||||||
|
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||||
}
|
}
|
||||||
return Object.values(result).some((item) => item !== undefined) ? result : undefined
|
return {
|
||||||
|
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||||
|
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||||
|
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||||
|
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSafetySettings(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return undefined
|
||||||
|
const settings = value.flatMap((item) =>
|
||||||
|
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||||
|
? [{ category: item.category, threshold: item.threshold }]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||||
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
|
const hasTools = request.tools.length > 0
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
|
const options = resolveOptions(request)
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const generationConfig = {
|
const generationConfig = {
|
||||||
maxOutputTokens: generation?.maxTokens,
|
maxOutputTokens: generation?.maxTokens,
|
||||||
@@ -309,14 +389,17 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
topP: generation?.topP,
|
topP: generation?.topP,
|
||||||
topK: generation?.topK,
|
topK: generation?.topK,
|
||||||
stopSequences: generation?.stop,
|
stopSequences: generation?.stop,
|
||||||
thinkingConfig: thinkingConfig(request),
|
thinkingConfig: options.thinkingConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
cachedContent: options.cachedContent,
|
||||||
contents: yield* lowerMessages(request),
|
contents: yield* lowerMessages(request),
|
||||||
|
safetySettings: options.safetySettings,
|
||||||
|
serviceTier: options.serviceTier,
|
||||||
systemInstruction:
|
systemInstruction:
|
||||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||||
tools: toolsEnabled
|
tools: hasTools
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
functionDeclarations: request.tools.map((tool) =>
|
functionDeclarations: request.tools.map((tool) =>
|
||||||
@@ -325,7 +408,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
||||||
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
||||||
? generationConfig
|
? generationConfig
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -369,10 +452,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
finishReason === "SAFETY" ||
|
finishReason === "SAFETY" ||
|
||||||
finishReason === "BLOCKLIST" ||
|
finishReason === "BLOCKLIST" ||
|
||||||
finishReason === "PROHIBITED_CONTENT" ||
|
finishReason === "PROHIBITED_CONTENT" ||
|
||||||
finishReason === "SPII"
|
finishReason === "SPII" ||
|
||||||
|
finishReason === "MODEL_ARMOR" ||
|
||||||
|
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
||||||
|
finishReason === "IMAGE_RECITATION" ||
|
||||||
|
finishReason === "LANGUAGE"
|
||||||
)
|
)
|
||||||
return "content-filter"
|
return "content-filter"
|
||||||
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
|
if (
|
||||||
|
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
||||||
|
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
||||||
|
finishReason === "NO_IMAGE" ||
|
||||||
|
finishReason === "TOO_MANY_TOOL_CALLS" ||
|
||||||
|
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
|
||||||
|
finishReason === "MALFORMED_RESPONSE"
|
||||||
|
)
|
||||||
|
return "error"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +484,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
|||||||
)
|
)
|
||||||
: state.lifecycle
|
: state.lifecycle
|
||||||
Lifecycle.finish(lifecycle, events, {
|
Lifecycle.finish(lifecycle, events, {
|
||||||
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
reason: {
|
||||||
|
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||||
|
raw: state.finishReason,
|
||||||
|
},
|
||||||
usage: state.usage,
|
usage: state.usage,
|
||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
@@ -441,6 +539,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
|
const metadata = {
|
||||||
|
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||||
|
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||||
|
}
|
||||||
lifecycle = Lifecycle.reasoningEnd(
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
lifecycle,
|
lifecycle,
|
||||||
events,
|
events,
|
||||||
@@ -453,9 +555,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
id,
|
id,
|
||||||
name: part.functionCall.name,
|
name: part.functionCall.name,
|
||||||
input,
|
input,
|
||||||
providerMetadata: part.thoughtSignature
|
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
|
||||||
: undefined,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
hasToolCalls = true
|
hasToolCalls = true
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import {
|
||||||
|
GeneratedImage,
|
||||||
|
ImageModel,
|
||||||
|
ImageResponse,
|
||||||
|
type ImageInput,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
AIError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
type ProviderMetadata,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "google-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
export type GoogleImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type GoogleImageOptions = {
|
||||||
|
readonly aspectRatio?: GoogleImageString<
|
||||||
|
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
|
||||||
|
>
|
||||||
|
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
|
||||||
|
readonly seed?: number
|
||||||
|
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
|
||||||
|
readonly includeThoughts?: boolean
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
export type GoogleImageBody = Record<string, unknown> & {
|
||||||
|
readonly contents: ReadonlyArray<{
|
||||||
|
readonly role: "user"
|
||||||
|
readonly parts: ReadonlyArray<Record<string, unknown>>
|
||||||
|
}>
|
||||||
|
readonly generationConfig: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleUsage = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||||
|
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokenCount: Schema.optional(Schema.Number),
|
||||||
|
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||||
|
totalTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
candidatesTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const GoogleImageResponse = Schema.Struct({
|
||||||
|
candidates: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
index: Schema.optional(Schema.Number),
|
||||||
|
content: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
parts: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
text: Schema.optional(Schema.String),
|
||||||
|
thought: Schema.optional(Schema.Boolean),
|
||||||
|
thoughtSignature: Schema.optional(Schema.String),
|
||||||
|
inlineData: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
mimeType: Schema.String,
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
finishReason: Schema.optional(Schema.String),
|
||||||
|
finishMessage: Schema.optional(Schema.String),
|
||||||
|
safetyRatings: Schema.optional(Schema.Unknown),
|
||||||
|
citationMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
groundingMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
usageMetadata: Schema.optional(GoogleUsage),
|
||||||
|
modelVersion: Schema.optional(Schema.String),
|
||||||
|
responseId: Schema.optional(Schema.String),
|
||||||
|
promptFeedback: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||||
|
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
|
||||||
|
const image = {
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
}
|
||||||
|
const thinkingConfig = {
|
||||||
|
thinkingLevel,
|
||||||
|
includeThoughts,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
mergeJsonRecords(
|
||||||
|
{
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
|
||||||
|
seed,
|
||||||
|
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
|
||||||
|
},
|
||||||
|
native,
|
||||||
|
) ?? { responseModalities: ["IMAGE"] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||||
|
new AIError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<GoogleImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
|
||||||
|
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
|
||||||
|
generationConfig: nativeOptions(request.options),
|
||||||
|
},
|
||||||
|
http?.body,
|
||||||
|
) as GoogleImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const candidates = decoded.candidates ?? []
|
||||||
|
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||||
|
index: candidate.index ?? candidateIndex,
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
finishMessage: candidate.finishMessage,
|
||||||
|
safetyRatings: candidate.safetyRatings,
|
||||||
|
citationMetadata: candidate.citationMetadata,
|
||||||
|
groundingMetadata: candidate.groundingMetadata,
|
||||||
|
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||||
|
part.inlineData === undefined
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
text: part.text,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: part.inlineData.mimeType,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||||
|
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||||
|
part.inlineData === undefined || part.thought === true
|
||||||
|
? []
|
||||||
|
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(encoded, (item) =>
|
||||||
|
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||||
|
Effect.mapError(() =>
|
||||||
|
invalidOutput(
|
||||||
|
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: item.inlineData.mimeType,
|
||||||
|
data,
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||||
|
partIndex: item.partIndex,
|
||||||
|
finishReason: item.candidate.finishReason,
|
||||||
|
safetyRatings: item.candidate.safetyRatings,
|
||||||
|
citationMetadata: item.candidate.citationMetadata,
|
||||||
|
groundingMetadata: item.candidate.groundingMetadata,
|
||||||
|
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (images.length === 0) {
|
||||||
|
const finishReasons = candidates.flatMap((candidate) =>
|
||||||
|
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||||
|
)
|
||||||
|
return yield* invalidOutput(
|
||||||
|
`Google Images returned no final images${
|
||||||
|
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||||
|
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||||
|
{
|
||||||
|
google: {
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const usage = decoded.usageMetadata
|
||||||
|
const outputTokens =
|
||||||
|
usage?.candidatesTokenCount === undefined
|
||||||
|
? undefined
|
||||||
|
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: usage.promptTokenCount,
|
||||||
|
outputTokens,
|
||||||
|
nonCachedInputTokens: ProviderShared.subtractTokens(
|
||||||
|
usage.promptTokenCount,
|
||||||
|
usage.cachedContentTokenCount,
|
||||||
|
),
|
||||||
|
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||||
|
reasoningTokens: usage.thoughtsTokenCount,
|
||||||
|
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||||
|
providerMetadata: { google: usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
modelVersion: decoded.modelVersion,
|
||||||
|
responseId: decoded.responseId,
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, AIError> => {
|
||||||
|
if (image.type === "bytes")
|
||||||
|
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||||
|
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||||
|
if (image.type === "url")
|
||||||
|
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||||
|
Effect.flatMap((decoded) => {
|
||||||
|
if (decoded === undefined)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(
|
||||||
|
ADAPTER,
|
||||||
|
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Effect.succeed({
|
||||||
|
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GoogleImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Effect, Schema, Struct } from "effect"
|
|
||||||
import { AnthropicMessages } from "./anthropic-messages"
|
|
||||||
import { Auth } from "../route/auth"
|
|
||||||
import { Route } from "../route/client"
|
|
||||||
import { Endpoint } from "../route/endpoint"
|
|
||||||
import { Framing } from "../route/framing"
|
|
||||||
import { Protocol } from "../route/protocol"
|
|
||||||
|
|
||||||
const VERSION = "vertex-2023-10-16" as const
|
|
||||||
|
|
||||||
export const GoogleVertexAnthropicBody = Schema.Struct({
|
|
||||||
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
|
|
||||||
anthropic_version: Schema.Literal(VERSION),
|
|
||||||
})
|
|
||||||
export type GoogleVertexAnthropicBody = Schema.Schema.Type<typeof GoogleVertexAnthropicBody>
|
|
||||||
|
|
||||||
export const protocol = Protocol.make({
|
|
||||||
id: "google-vertex-anthropic",
|
|
||||||
body: {
|
|
||||||
schema: GoogleVertexAnthropicBody,
|
|
||||||
from: (request) =>
|
|
||||||
AnthropicMessages.protocol.body.from(request).pipe(
|
|
||||||
Effect.map((body) => ({
|
|
||||||
...Struct.omit(body, ["model"]),
|
|
||||||
anthropic_version: VERSION,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
stream: AnthropicMessages.protocol.stream,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const route = Route.make({
|
|
||||||
id: "google-vertex-anthropic",
|
|
||||||
provider: "google-vertex-anthropic",
|
|
||||||
providerMetadataKey: "anthropic",
|
|
||||||
protocol,
|
|
||||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
|
||||||
auth: Auth.none,
|
|
||||||
framing: Framing.sse,
|
|
||||||
})
|
|
||||||
|
|
||||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Gemini } from "./gemini"
|
|
||||||
import { Auth } from "../route/auth"
|
|
||||||
import { Route } from "../route/client"
|
|
||||||
import { Endpoint } from "../route/endpoint"
|
|
||||||
import { Framing } from "../route/framing"
|
|
||||||
|
|
||||||
export const route = Route.make({
|
|
||||||
id: "google-vertex-gemini",
|
|
||||||
provider: "google-vertex",
|
|
||||||
providerMetadataKey: "google",
|
|
||||||
protocol: Gemini.protocol,
|
|
||||||
endpoint: Endpoint.path(({ request }) => {
|
|
||||||
const model = String(request.model.id)
|
|
||||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
|
||||||
}),
|
|
||||||
auth: Auth.none,
|
|
||||||
framing: Framing.sse,
|
|
||||||
})
|
|
||||||
|
|
||||||
export * as GoogleVertexGemini from "./google-vertex-gemini"
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export * as AnthropicMessages from "./anthropic-messages"
|
export * as AnthropicMessages from "./anthropic-messages"
|
||||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
|
||||||
export * as GoogleVertexGemini from "./google-vertex-gemini"
|
|
||||||
export * as BedrockConverse from "./bedrock-converse"
|
export * as BedrockConverse from "./bedrock-converse"
|
||||||
export * as Gemini from "./gemini"
|
export * as Gemini from "./gemini"
|
||||||
export * as OpenAIChat from "./openai-chat"
|
export * as OpenAIChat from "./openai-chat"
|
||||||
|
export * as OpenAIImages from "./openai-images"
|
||||||
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenAIResponses from "./openai-responses"
|
export * as OpenAIResponses from "./openai-responses"
|
||||||
|
export * as OpenResponses from "./open-responses"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,17 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { HttpTransport } from "../route/transport"
|
import { HttpTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
|
type FinishReasonDetails,
|
||||||
|
type CacheHint,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
@@ -15,8 +19,8 @@ import {
|
|||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
|
import { classifyProviderFailure } from "../provider-error"
|
||||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { OpenAIOptions } from "./utils/openai-options"
|
import { OpenAIOptions } from "./utils/openai-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
@@ -25,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
|
|||||||
|
|
||||||
const ADAPTER = "openai-chat"
|
const ADAPTER = "openai-chat"
|
||||||
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
||||||
|
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/chat/completions"
|
export const PATH = "/chat/completions"
|
||||||
|
|
||||||
@@ -34,6 +39,11 @@ export const PATH = "/chat/completions"
|
|||||||
// The body schema is the provider-native JSON body. `fromRequest` below builds
|
// The body schema is the provider-native JSON body. `fromRequest` below builds
|
||||||
// this shape from the common `LLMRequest`, then `Route.make` validates and
|
// this shape from the common `LLMRequest`, then `Route.make` validates and
|
||||||
// JSON-encodes it before transport.
|
// JSON-encodes it before transport.
|
||||||
|
const OpenAIChatCacheControl = Schema.Struct({
|
||||||
|
type: Schema.Literal("ephemeral"),
|
||||||
|
ttl: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
const OpenAIChatFunction = Schema.Struct({
|
const OpenAIChatFunction = Schema.Struct({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
description: Schema.String,
|
description: Schema.String,
|
||||||
@@ -43,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
|
|||||||
const OpenAIChatTool = Schema.Struct({
|
const OpenAIChatTool = Schema.Struct({
|
||||||
type: Schema.tag("function"),
|
type: Schema.tag("function"),
|
||||||
function: OpenAIChatFunction,
|
function: OpenAIChatFunction,
|
||||||
|
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||||
})
|
})
|
||||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||||
|
|
||||||
@@ -57,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
|||||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||||
|
|
||||||
const OpenAIChatUserContent = Schema.Union([
|
const OpenAIChatUserContent = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("text"),
|
||||||
|
text: Schema.String,
|
||||||
|
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||||
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.Literal("image_url"),
|
type: Schema.Literal("image_url"),
|
||||||
image_url: Schema.Struct({ url: Schema.String }),
|
image_url: Schema.Struct({ url: Schema.String }),
|
||||||
@@ -65,18 +80,33 @@ const OpenAIChatUserContent = Schema.Union([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const OpenAIChatMessage = Schema.Union([
|
const OpenAIChatMessage = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
Schema.Struct({
|
||||||
|
role: Schema.Literal("system"),
|
||||||
|
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||||
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
role: Schema.Literal("user"),
|
role: Schema.Literal("user"),
|
||||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||||
}),
|
}),
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.Literal("assistant"),
|
||||||
|
content: Schema.NullOr(Schema.String),
|
||||||
|
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||||
|
reasoning_content: Schema.optional(Schema.String),
|
||||||
|
reasoning: Schema.optional(Schema.String),
|
||||||
|
reasoning_text: Schema.optional(Schema.String),
|
||||||
|
reasoning_details: Schema.optional(Schema.Unknown),
|
||||||
|
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
role: Schema.Literal("assistant"),
|
role: Schema.Literal("tool"),
|
||||||
content: Schema.NullOr(Schema.String),
|
tool_call_id: Schema.String,
|
||||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
content: Schema.String,
|
||||||
reasoning_content: Schema.optional(Schema.String),
|
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||||
}),
|
}),
|
||||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||||
|
|
||||||
@@ -97,6 +127,7 @@ export const bodyFields = {
|
|||||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||||
|
max_completion_tokens: Schema.optional(Schema.Number),
|
||||||
max_tokens: Schema.optional(Schema.Number),
|
max_tokens: Schema.optional(Schema.Number),
|
||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Number),
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Number),
|
||||||
@@ -121,6 +152,7 @@ const OpenAIChatUsage = Schema.Struct({
|
|||||||
prompt_tokens_details: optionalNull(
|
prompt_tokens_details: optionalNull(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
cached_tokens: Schema.optional(Schema.Number),
|
cached_tokens: Schema.optional(Schema.Number),
|
||||||
|
cache_write_tokens: Schema.optional(Schema.Number),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
completion_tokens_details: optionalNull(
|
completion_tokens_details: optionalNull(
|
||||||
@@ -142,30 +174,54 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||||
|
|
||||||
const OpenAIChatDelta = Schema.Struct({
|
const OpenAIChatDelta = Schema.StructWithRest(
|
||||||
content: optionalNull(Schema.String),
|
Schema.Struct({
|
||||||
reasoning_content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
})
|
reasoning: optionalNull(Schema.String),
|
||||||
|
reasoning_text: optionalNull(Schema.String),
|
||||||
|
reasoning_details: optionalNull(Schema.Unknown),
|
||||||
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
const OpenAIChatChoice = Schema.Struct({
|
const OpenAIChatChoice = Schema.Struct({
|
||||||
delta: optionalNull(OpenAIChatDelta),
|
delta: optionalNull(OpenAIChatDelta),
|
||||||
finish_reason: optionalNull(Schema.String),
|
finish_reason: optionalNull(Schema.String),
|
||||||
|
native_finish_reason: optionalNull(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
const OpenAIChatError = Schema.Struct({
|
||||||
|
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||||
|
message: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const OpenAIChatEvent = Schema.Struct({
|
export const OpenAIChatEvent = Schema.Struct({
|
||||||
choices: Schema.Array(OpenAIChatChoice),
|
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||||
usage: optionalNull(OpenAIChatUsage),
|
usage: optionalNull(OpenAIChatUsage),
|
||||||
|
error: optionalNull(OpenAIChatError),
|
||||||
})
|
})
|
||||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||||
|
|
||||||
|
interface PendingToolDelta {
|
||||||
|
readonly id?: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly input: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParserState {
|
export interface ParserState {
|
||||||
readonly tools: ToolStream.State<number>
|
readonly tools: ToolStream.State<number>
|
||||||
|
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReasonDetails
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly reasoningField?: string
|
||||||
|
readonly reasoningDetails: Array<unknown>
|
||||||
|
readonly reasoningDetailsObserved: boolean
|
||||||
|
readonly reasoningEmitted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -174,13 +230,20 @@ export interface ParserState {
|
|||||||
// Lowering is the only place that knows how common LLM messages map onto the
|
// Lowering is the only place that knows how common LLM messages map onto the
|
||||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||||
// fields into `LLMRequest`.
|
// fields into `LLMRequest`.
|
||||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
interface LoweringOptions {
|
||||||
|
readonly cacheControl?: (
|
||||||
|
cache: CacheHint | undefined,
|
||||||
|
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||||
},
|
},
|
||||||
|
cache_control: options.cacheControl?.(tool.cache),
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||||
@@ -208,11 +271,28 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
|||||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||||
|
|
||||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
const reasoningField = (part: ReasoningPart) => {
|
||||||
|
const field = part.providerMetadata?.openai?.reasoningField
|
||||||
|
return typeof field === "string" ? field : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||||
|
const observed = parts.flatMap((part) => {
|
||||||
|
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||||
|
return Array.isArray(details) ? details : []
|
||||||
|
})
|
||||||
|
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||||
|
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||||
|
message: OpenAIChatRequestMessage,
|
||||||
|
options: LoweringOptions,
|
||||||
|
) {
|
||||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
content.push({ type: "text", text: part.text })
|
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "media") {
|
if (part.type === "media") {
|
||||||
@@ -221,13 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
|||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||||
}
|
}
|
||||||
if (content.every((part) => part.type === "text"))
|
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
return {
|
||||||
|
role: "user" as const,
|
||||||
|
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
|
||||||
|
}
|
||||||
return { role: "user" as const, content }
|
return { role: "user" as const, content }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||||
message: OpenAIChatRequestMessage,
|
message: OpenAIChatRequestMessage,
|
||||||
|
configuredField?: string,
|
||||||
|
options: LoweringOptions = {},
|
||||||
) {
|
) {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
const reasoning: ReasoningPart[] = []
|
const reasoning: ReasoningPart[] = []
|
||||||
@@ -248,30 +333,61 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
const text = reasoning.map((part) => part.text).join("")
|
||||||
|
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||||
|
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||||
|
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||||
|
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||||
|
const field = (() => {
|
||||||
|
if (configuredField !== undefined) return configuredField
|
||||||
|
if (reasoning.length === 0) return undefined
|
||||||
|
if (observedField !== undefined) return observedField
|
||||||
|
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||||
|
if (!fullyStructured) return "reasoning_content"
|
||||||
|
})()
|
||||||
|
const reasoningText = (() => {
|
||||||
|
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
|
||||||
|
if (reasoning.length === 0) return nativeReasoning
|
||||||
|
return text
|
||||||
|
})()
|
||||||
|
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||||
|
const result = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content:
|
reasoning_details: details,
|
||||||
reasoning.length > 0
|
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
||||||
? reasoning.map((part) => part.text).join("")
|
|
||||||
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
|
||||||
}
|
}
|
||||||
|
if (field === undefined || reasoningText === undefined) return result
|
||||||
|
return { ...result, [field]: reasoningText }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||||
|
message: OpenAIChatRequestMessage,
|
||||||
|
options: LoweringOptions,
|
||||||
|
) {
|
||||||
const messages: OpenAIChatMessage[] = []
|
const messages: OpenAIChatMessage[] = []
|
||||||
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||||
if (part.result.type !== "content") {
|
if (part.result.type !== "content") {
|
||||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: part.id,
|
||||||
|
content: ProviderShared.toolResultText(part),
|
||||||
|
cache_control: options.cacheControl?.(part.cache),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: part.id,
|
||||||
|
content: text.join("\n"),
|
||||||
|
cache_control: options.cacheControl?.(part.cache),
|
||||||
|
})
|
||||||
const files = content.filter((item) => item.type === "file")
|
const files = content.filter((item) => item.type === "file")
|
||||||
images.push(
|
images.push(
|
||||||
...(yield* Effect.forEach(files, (item) =>
|
...(yield* Effect.forEach(files, (item) =>
|
||||||
@@ -282,15 +398,32 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
|||||||
return { messages, images }
|
return { messages, images }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
|
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
message: OpenAIChatRequestMessage,
|
||||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
|
reasoningField?: string,
|
||||||
return (yield* lowerToolMessages(message)).messages
|
options: LoweringOptions = {},
|
||||||
|
) {
|
||||||
|
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||||
|
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||||
|
return (yield* lowerToolMessages(message, options)).messages
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||||
const system: OpenAIChatMessage[] =
|
const system: OpenAIChatMessage[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0
|
||||||
|
? []
|
||||||
|
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: request.system.map((part) => ({
|
||||||
|
type: "text",
|
||||||
|
text: part.text,
|
||||||
|
cache_control: options.cacheControl?.(part.cache),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||||
const messages = [...system]
|
const messages = [...system]
|
||||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||||
const flushImages = () => {
|
const flushImages = () => {
|
||||||
@@ -301,67 +434,106 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
|||||||
if (message.role === "system") {
|
if (message.role === "system") {
|
||||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||||
if (pendingImages.length > 0) {
|
if (pendingImages.length > 0) {
|
||||||
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
...pendingImages.splice(0),
|
||||||
|
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||||
|
],
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const previous = messages.at(-1)
|
const previous = messages.at(-1)
|
||||||
if (previous?.role === "user" && typeof previous.content === "string")
|
if (previous?.role === "user" && typeof previous.content === "string")
|
||||||
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
|
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||||
|
? {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: previous.content },
|
||||||
|
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { role: "user", content: `${previous.content}\n${part.text}` }
|
||||||
else if (previous?.role === "user" && Array.isArray(previous.content))
|
else if (previous?.role === "user" && Array.isArray(previous.content))
|
||||||
messages[messages.length - 1] = {
|
messages[messages.length - 1] = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [...previous.content, { type: "text", text: part.text }],
|
content: [
|
||||||
|
...previous.content,
|
||||||
|
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
else messages.push({ role: "user", content: part.text })
|
else
|
||||||
|
messages.push(
|
||||||
|
options.cacheControl?.(part.cache)
|
||||||
|
? {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
|
||||||
|
}
|
||||||
|
: { role: "user", content: part.text },
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (message.role === "tool") {
|
if (message.role === "tool") {
|
||||||
const lowered = yield* lowerToolMessages(message)
|
const lowered = yield* lowerToolMessages(message, options)
|
||||||
messages.push(...lowered.messages)
|
messages.push(...lowered.messages)
|
||||||
pendingImages.push(...lowered.images)
|
pendingImages.push(...lowered.images)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
flushImages()
|
flushImages()
|
||||||
messages.push(...(yield* lowerMessage(message)))
|
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||||
}
|
}
|
||||||
flushImages()
|
flushImages()
|
||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
const lowerOptions = (request: LLMRequest) => {
|
||||||
const store = OpenAIOptions.store(request)
|
const options = OpenAIOptions.resolve(request)
|
||||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
|
||||||
return {
|
return {
|
||||||
...(store !== undefined ? { store } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||||
|
request: LLMRequest,
|
||||||
|
options: LoweringOptions = {},
|
||||||
|
) {
|
||||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||||
// validation, and HTTP execution are composed by `Route.make`.
|
// validation, and HTTP execution are composed by `Route.make`.
|
||||||
|
const reasoningField = request.model.compatibility?.reasoningField
|
||||||
|
if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField))
|
||||||
|
return yield* ProviderShared.invalidRequest(
|
||||||
|
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
|
||||||
|
)
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
|
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||||
return {
|
return {
|
||||||
model: request.model.id,
|
model: request.model.id,
|
||||||
messages: yield* lowerMessages(request),
|
messages: yield* lowerMessages(request, options),
|
||||||
tools:
|
tools:
|
||||||
request.tools.length === 0
|
request.tools.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) =>
|
: request.tools.map((tool) =>
|
||||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
lowerTool(
|
||||||
|
tool,
|
||||||
|
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||||
|
options,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||||
stream: true as const,
|
stream: true as const,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
max_tokens: generation?.maxTokens,
|
...(maxTokensField === "max_completion_tokens"
|
||||||
|
? { max_completion_tokens: generation?.maxTokens }
|
||||||
|
: { max_tokens: generation?.maxTokens }),
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
frequency_penalty: generation?.frequencyPenalty,
|
frequency_penalty: generation?.frequencyPenalty,
|
||||||
presence_penalty: generation?.presencePenalty,
|
presence_penalty: generation?.presencePenalty,
|
||||||
seed: generation?.seed,
|
seed: generation?.seed,
|
||||||
stop: generation?.stop,
|
stop: generation?.stop,
|
||||||
...(yield* lowerOptions(request)),
|
...lowerOptions(request),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -376,58 +548,171 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
if (reason === "length") return "length"
|
if (reason === "length") return "length"
|
||||||
if (reason === "content_filter") return "content-filter"
|
if (reason === "content_filter") return "content-filter"
|
||||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||||
|
if (reason === "error") return "error"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
|
||||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||||
// satisfied on both sides.
|
// satisfied on both sides.
|
||||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||||
|
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
||||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.prompt_tokens,
|
inputTokens: usage.prompt_tokens,
|
||||||
outputTokens: usage.completion_tokens,
|
outputTokens: usage.completion_tokens,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||||
providerMetadata: { openai: usage },
|
providerMetadata: { openai: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasoningDelta = (
|
||||||
|
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
||||||
|
configuredField?: string,
|
||||||
|
) => {
|
||||||
|
if (!delta) return undefined
|
||||||
|
const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"])
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field === undefined) continue
|
||||||
|
const text = delta[field]
|
||||||
|
if (typeof text === "string" && text.length > 0) return { field, text }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||||
|
const text = details.flatMap((detail) => {
|
||||||
|
if (!isRecord(detail)) return []
|
||||||
|
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||||
|
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||||
|
return [detail.summary]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
if (text.length > 0) return text.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||||
|
for (const detail of details) {
|
||||||
|
const previous = result.at(-1)
|
||||||
|
if (
|
||||||
|
!isRecord(previous) ||
|
||||||
|
previous.type !== "reasoning.text" ||
|
||||||
|
!isRecord(detail) ||
|
||||||
|
detail.type !== "reasoning.text" ||
|
||||||
|
conflictingReasoningTextDetails(previous, detail)
|
||||||
|
) {
|
||||||
|
result.push(detail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[result.length - 1] = {
|
||||||
|
...previous,
|
||||||
|
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||||
|
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||||
|
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||||
|
format: mergeDetailValue(previous.format, detail.format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||||
|
previous || current || (previous !== undefined ? previous : current)
|
||||||
|
|
||||||
|
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||||
|
conflictingDetailValue(previous.id, current.id) ||
|
||||||
|
conflictingDetailValue(previous.index, current.index) ||
|
||||||
|
conflictingDetailValue(previous.format, current.format) ||
|
||||||
|
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
|
||||||
|
|
||||||
|
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||||
|
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||||
|
|
||||||
|
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||||
|
openai: {
|
||||||
|
...(field ? { reasoningField: field } : {}),
|
||||||
|
...(details ? { reasoningDetails: details } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
if (event.error)
|
||||||
|
return yield* new AIError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "stream",
|
||||||
|
reason: classifyProviderFailure({
|
||||||
|
message: event.error.message,
|
||||||
|
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||||
|
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const usage = mapUsage(event.usage) ?? state.usage
|
const usage = mapUsage(event.usage) ?? state.usage
|
||||||
const choice = event.choices[0]
|
const choice = event.choices?.[0]
|
||||||
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
|
const finishReason = choice?.finish_reason
|
||||||
|
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
||||||
|
: state.finishReason
|
||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
|
let pendingTools = state.pendingTools
|
||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
if (delta?.reasoning_content)
|
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||||
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
|
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||||
|
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||||
|
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||||
|
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
||||||
|
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||||
|
else if (
|
||||||
|
reasoningDetailsObserved &&
|
||||||
|
!lifecycle.reasoning.has("reasoning-0") &&
|
||||||
|
(Boolean(delta?.content) || toolDeltas.length > 0)
|
||||||
|
)
|
||||||
|
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||||
|
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||||
|
|
||||||
if (delta?.content) {
|
if (delta?.content) {
|
||||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"reasoning-0",
|
||||||
|
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||||
|
)
|
||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
for (const tool of toolDeltas) {
|
||||||
|
const current = tools[tool.index]
|
||||||
|
const pending = pendingTools[tool.index]
|
||||||
|
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||||
|
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||||
|
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||||
|
if (!current && (!id || !name)) {
|
||||||
|
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
pendingTools = { ...pendingTools }
|
||||||
|
delete pendingTools[tool.index]
|
||||||
|
}
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
tools,
|
tools,
|
||||||
tool.index,
|
tool.index,
|
||||||
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
|
{ id: id || undefined, name: name || undefined, text },
|
||||||
"OpenAI Chat tool call delta is missing id or name",
|
"OpenAI Chat tool call delta is missing id or name",
|
||||||
)
|
)
|
||||||
if (ToolStream.isError(result)) return yield* result
|
if (ToolStream.isError(result)) return yield* result
|
||||||
@@ -436,8 +721,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
events.push(...result.events)
|
events.push(...result.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||||
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||||
|
|
||||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||||
// JSON parse failures fail the stream at the boundary rather than at halt.
|
// valid calls and malformed local calls settle independently.
|
||||||
const finished =
|
const finished =
|
||||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||||
@@ -446,10 +734,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tools: finished?.tools ?? tools,
|
tools: finished?.tools ?? tools,
|
||||||
|
pendingTools,
|
||||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||||
usage,
|
usage,
|
||||||
finishReason,
|
finishReason,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
reasoningField,
|
||||||
|
reasoningDetails: state.reasoningDetails,
|
||||||
|
reasoningDetailsObserved,
|
||||||
|
reasoningEmitted,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -458,8 +751,23 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
const reason = state.finishReason
|
||||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
? {
|
||||||
|
...state.finishReason,
|
||||||
|
normalized:
|
||||||
|
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const metadata = reasoningMetadata(
|
||||||
|
state.reasoningField,
|
||||||
|
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||||
|
)
|
||||||
|
const started =
|
||||||
|
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||||
|
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||||
|
: state.lifecycle
|
||||||
|
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||||
|
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||||
events.push(...state.toolCallEvents)
|
events.push(...state.toolCallEvents)
|
||||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||||
return events
|
return events
|
||||||
@@ -482,7 +790,16 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
initial: (request) => ({
|
||||||
|
tools: ToolStream.empty<number>(),
|
||||||
|
pendingTools: {},
|
||||||
|
toolCallEvents: [],
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
reasoningField: request.model.compatibility?.reasoningField,
|
||||||
|
reasoningDetails: [],
|
||||||
|
reasoningDetailsObserved: false,
|
||||||
|
reasoningEmitted: false,
|
||||||
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import * as OpenAIChat from "./openai-chat"
|
import * as OpenAIChat from "./openai-chat"
|
||||||
|
|
||||||
const ADAPTER = "openai-compatible-chat"
|
const ADAPTER = "openai-compatible-chat"
|
||||||
|
|
||||||
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
export type OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInput
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
|
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { OpenAIResponses } from "./openai-responses"
|
import { OpenResponses } from "./open-responses"
|
||||||
|
|
||||||
const ADAPTER = "openai-compatible-responses"
|
const ADAPTER = "openai-compatible-responses"
|
||||||
|
|
||||||
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput
|
export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageModelInput
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route for providers that expose an OpenAI Responses-compatible `/responses`
|
* Deployment adapter for providers that expose an Open Responses-compatible
|
||||||
* endpoint. Provider helpers configure identity, endpoint, and auth before
|
* `/responses` endpoint. Provider helpers configure identity, endpoint, and
|
||||||
* model selection while this route reuses the OpenAI Responses protocol.
|
* auth while the semantic protocol remains provider-neutral.
|
||||||
*/
|
*/
|
||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
providerMetadataKey: "openai",
|
providerMetadataKey: "openresponses",
|
||||||
protocol: OpenAIResponses.protocol,
|
protocol: OpenResponses.protocol,
|
||||||
endpoint: Endpoint.path(OpenAIResponses.PATH),
|
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||||
transport: OpenAIResponses.httpTransport,
|
transport: OpenResponses.httpTransport,
|
||||||
defaults: { providerOptions: { openai: { store: false } } },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import {
|
||||||
|
ImageModel,
|
||||||
|
GeneratedImage,
|
||||||
|
ImageResponse,
|
||||||
|
type ImageInput,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
AIError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
|
const ADAPTER = "openai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
|
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type OpenAIImageOptions = {
|
||||||
|
readonly mask?: ImageInput
|
||||||
|
readonly n?: number
|
||||||
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
|
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||||
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
|
readonly outputCompression?: number
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
export type OpenAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OpenAIImageResponse = Schema.Struct({
|
||||||
|
data: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
b64_json: Schema.optional(Schema.String),
|
||||||
|
url: Schema.optional(Schema.String),
|
||||||
|
revised_prompt: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
output_format: Schema.optional(Schema.String),
|
||||||
|
usage: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
input_tokens: Schema.optional(Schema.Number),
|
||||||
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
|
total_tokens: Schema.optional(Schema.Number),
|
||||||
|
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { mask: _, outputFormat, outputCompression, ...native } = options
|
||||||
|
return {
|
||||||
|
output_format: outputFormat,
|
||||||
|
output_compression: outputCompression,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new AIError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<OpenAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||||
|
const mask = request.options?.mask
|
||||||
|
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const sourceImages = request.images ?? []
|
||||||
|
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||||
|
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||||
|
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
})
|
||||||
|
const multipartMask =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { data: mask.data, mediaType: mask.mediaType }
|
||||||
|
: mask.type === "url"
|
||||||
|
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||||
|
: undefined
|
||||||
|
const useMultipart =
|
||||||
|
sourceImages.length > 0 &&
|
||||||
|
multipartImages.every((image) => image !== undefined) &&
|
||||||
|
(mask === undefined || multipartMask !== undefined)
|
||||||
|
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
|
||||||
|
|
||||||
|
if (useMultipart) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("model", request.model.id)
|
||||||
|
form.append("prompt", request.prompt)
|
||||||
|
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
|
||||||
|
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
|
||||||
|
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||||
|
})
|
||||||
|
multipartImages.forEach((image, index) => {
|
||||||
|
if (image === undefined) return
|
||||||
|
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
|
||||||
|
})
|
||||||
|
if (multipartMask !== undefined)
|
||||||
|
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: "[multipart/form-data]",
|
||||||
|
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
|
||||||
|
)
|
||||||
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = sourceImages.map((image) => {
|
||||||
|
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
|
||||||
|
if (image.type === "url") return { image_url: image.url }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (references.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
|
const maskReference =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { image_url: ImageInputs.dataUrl(mask) }
|
||||||
|
: mask.type === "url"
|
||||||
|
? { image_url: mask.url }
|
||||||
|
: mask.type === "file-id"
|
||||||
|
? { file_id: mask.id }
|
||||||
|
: undefined
|
||||||
|
if (mask !== undefined && maskReference === undefined)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
images: references.length === 0 ? undefined : references,
|
||||||
|
mask: maskReference,
|
||||||
|
},
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as OpenAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||||
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
|
options: OpenAIImageOptions | undefined,
|
||||||
|
overlay: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||||
|
const format =
|
||||||
|
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
decoded.usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: decoded.usage.input_tokens,
|
||||||
|
outputTokens: decoded.usage.output_tokens,
|
||||||
|
totalTokens: decoded.usage.total_tokens,
|
||||||
|
providerMetadata: { openai: decoded.usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: { openai: { outputFormat: format } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageBlob = (data: Uint8Array, mediaType: string) => {
|
||||||
|
const buffer = new ArrayBuffer(data.byteLength)
|
||||||
|
new Uint8Array(buffer).set(data)
|
||||||
|
return new Blob([buffer], { type: mediaType })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
|||||||
import { Buffer } from "node:buffer"
|
import { Buffer } from "node:buffer"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
InvalidProviderOutputReason,
|
InvalidProviderOutputReason,
|
||||||
InvalidRequestReason,
|
InvalidRequestReason,
|
||||||
LLMError,
|
AIError,
|
||||||
type ContentPart,
|
type ContentPart,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
type ToolFileContent,
|
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolResultPart,
|
type ToolResultPart,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
@@ -41,7 +41,7 @@ export interface ToolAccumulator {
|
|||||||
* when at least one is defined. Returns `undefined` when neither input nor
|
* when at least one is defined. Returns `undefined` when neither input nor
|
||||||
* output is known so routes don't publish a misleading `0`.
|
* output is known so routes don't publish a misleading `0`.
|
||||||
*
|
*
|
||||||
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
|
* Under the additive `AI.Usage` contract, `inputTokens` and `outputTokens`
|
||||||
* are the non-cached input and visible output only. The provider-supplied
|
* are the non-cached input and visible output only. The provider-supplied
|
||||||
* `total` is the source of truth when present; the computed fallback
|
* `total` is the source of truth when present; the computed fallback
|
||||||
* under-counts cache and reasoning by design and exists mainly so
|
* under-counts cache and reasoning by design and exists mainly so
|
||||||
@@ -88,7 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const eventError = (route: string, message: string, raw?: string) =>
|
export const eventError = (route: string, message: string, raw?: string) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: "ProviderShared",
|
module: "ProviderShared",
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||||
@@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
|
|||||||
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
||||||
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
||||||
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
||||||
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
|
export const PDF_MIMES = ["application/pdf"] as const
|
||||||
|
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
|
||||||
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
||||||
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
|||||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||||
|
|
||||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||||
@@ -237,9 +238,9 @@ export const errorText = (error: unknown) => {
|
|||||||
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
||||||
* `Retry` control event on its error channel; we drop it here (we don't
|
* `Retry` control event on its error channel; we drop it here (we don't
|
||||||
* implement client-driven retries) so the public error channel stays
|
* implement client-driven retries) so the public error channel stays
|
||||||
* `LLMError`.
|
* `AIError`.
|
||||||
*/
|
*/
|
||||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
|
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
||||||
bytes.pipe(
|
bytes.pipe(
|
||||||
Stream.decodeText(),
|
Stream.decodeText(),
|
||||||
Stream.pipeThroughChannel(Sse.decode()),
|
Stream.pipeThroughChannel(Sse.decode()),
|
||||||
@@ -256,7 +257,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
|||||||
* lands here.
|
* lands here.
|
||||||
*/
|
*/
|
||||||
export const invalidRequest = (message: string) =>
|
export const invalidRequest = (message: string) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: "ProviderShared",
|
module: "ProviderShared",
|
||||||
method: "request",
|
method: "request",
|
||||||
reason: new InvalidRequestReason({ message }),
|
reason: new InvalidRequestReason({ message }),
|
||||||
@@ -303,7 +304,7 @@ export const unsupportedContent = (
|
|||||||
* Build a `validate` step from a Schema decoder. Replaces the per-route
|
* Build a `validate` step from a Schema decoder. Replaces the per-route
|
||||||
* lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
|
* lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
|
||||||
* invalid(e.message)))`. Any decode error is translated into
|
* invalid(e.message)))`. Any decode error is translated into
|
||||||
* `LLMError` carrying the original parse-error message.
|
* `AIError` carrying the original parse-error message.
|
||||||
*/
|
*/
|
||||||
export const validateWith =
|
export const validateWith =
|
||||||
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
|
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const signRequest = (input: {
|
|||||||
readonly body: string
|
readonly body: string
|
||||||
readonly headers: Headers.Headers
|
readonly headers: Headers.Headers
|
||||||
readonly credentials: Credentials
|
readonly credentials: Credentials
|
||||||
|
readonly service: string
|
||||||
|
readonly name: string
|
||||||
}) =>
|
}) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: async () => {
|
try: async () => {
|
||||||
@@ -34,23 +36,26 @@ const signRequest = (input: {
|
|||||||
accessKeyId: input.credentials.accessKeyId,
|
accessKeyId: input.credentials.accessKeyId,
|
||||||
secretAccessKey: input.credentials.secretAccessKey,
|
secretAccessKey: input.credentials.secretAccessKey,
|
||||||
sessionToken: input.credentials.sessionToken,
|
sessionToken: input.credentials.sessionToken,
|
||||||
service: "bedrock",
|
service: input.service,
|
||||||
}).sign()
|
}).sign()
|
||||||
return Object.fromEntries(signed.headers.entries())
|
return Object.fromEntries(signed.headers.entries())
|
||||||
},
|
},
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
ProviderShared.invalidRequest(
|
ProviderShared.invalidRequest(
|
||||||
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
`${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||||
export const sigV4 = (credentials: Credentials | undefined) =>
|
export const sigV4 = (
|
||||||
|
credentials: Credentials | undefined,
|
||||||
|
options: { readonly service?: string; readonly name?: string } = {},
|
||||||
|
) =>
|
||||||
Auth.custom((input: AuthInput) => {
|
Auth.custom((input: AuthInput) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return yield* ProviderShared.invalidRequest(
|
return yield* ProviderShared.invalidRequest(
|
||||||
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
|
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||||
@@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) =>
|
|||||||
body: input.body,
|
body: input.body,
|
||||||
headers: headersForSigning,
|
headers: headersForSigning,
|
||||||
credentials,
|
credentials,
|
||||||
|
service: options.service ?? "bedrock",
|
||||||
|
name: options.name ?? "Bedrock Converse",
|
||||||
})
|
})
|
||||||
return Headers.setAll(headersForSigning, signed)
|
return Headers.setAll(headersForSigning, signed)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
|
|||||||
"text/markdown": "md",
|
"text/markdown": "md",
|
||||||
} as const satisfies Record<string, DocumentFormat>
|
} as const satisfies Record<string, DocumentFormat>
|
||||||
|
|
||||||
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
||||||
document: {
|
document: {
|
||||||
format,
|
format,
|
||||||
name: part.filename ?? `document.${format}`,
|
name,
|
||||||
source: { bytes },
|
source: { bytes },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
|||||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||||
if (documentFormat) {
|
if (documentFormat) {
|
||||||
|
if (!part.filename)
|
||||||
|
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||||
const media = yield* ProviderShared.validateMedia(
|
const media = yield* ProviderShared.validateMedia(
|
||||||
"Bedrock Converse",
|
"Bedrock Converse",
|
||||||
part,
|
part,
|
||||||
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
||||||
)
|
)
|
||||||
return documentBlock(part, documentFormat, media.base64)
|
return documentBlock(part.filename, documentFormat, media.base64)
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Effect, Encoding } from "effect"
|
||||||
|
import type { ImageInput } from "../../image"
|
||||||
|
import { InvalidRequestReason, AIError } from "../../schema"
|
||||||
|
|
||||||
|
const invalid = (module: string, message: string) =>
|
||||||
|
new AIError({
|
||||||
|
module,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidRequestReason({ message }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||||
|
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
|
||||||
|
|
||||||
|
export const decodeDataUrl = (
|
||||||
|
url: string,
|
||||||
|
module: string,
|
||||||
|
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||||
|
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||||
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||||
|
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||||
|
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||||
|
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invalidImageInput = invalid
|
||||||
|
|
||||||
|
export const ImageInputs = {
|
||||||
|
dataUrl,
|
||||||
|
decodeDataUrl,
|
||||||
|
invalid: invalidImageInput,
|
||||||
|
} as const
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
readonly stepStarted: boolean
|
readonly stepStarted: boolean
|
||||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
|||||||
return { ...state, stepStarted: true }
|
return { ...state, stepStarted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
|
if (state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
if (stepped.text.has(id)) {
|
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||||
events.push(LLMEvent.textDelta({ id, text }))
|
|
||||||
return stepped
|
|
||||||
}
|
|
||||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
|
||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
|
const started = textStart(state, events, id)
|
||||||
|
events.push(LLMEvent.textDelta({ id, text }))
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
export const reasoningStart = (
|
export const reasoningStart = (
|
||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
@@ -44,7 +47,7 @@ export const reasoningDelta = (
|
|||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
): State => {
|
): State => {
|
||||||
const started = reasoningStart(state, events, id, providerMetadata)
|
const started = reasoningStart(state, events, id, providerMetadata)
|
||||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ export const finish = (
|
|||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
input: {
|
input: {
|
||||||
readonly reason: FinishReason
|
readonly reason: FinishReasonDetails
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly providerMetadata?: ProviderMetadata
|
readonly providerMetadata?: ProviderMetadata
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Schema } from "effect"
|
||||||
|
import { TextVerbosity, type LLMRequest } from "../../schema"
|
||||||
|
|
||||||
|
export const ResponseIncludables = [
|
||||||
|
"file_search_call.results",
|
||||||
|
"web_search_call.results",
|
||||||
|
"web_search_call.action.sources",
|
||||||
|
"message.input_image.image_url",
|
||||||
|
"computer_call_output.output.image_url",
|
||||||
|
"code_interpreter_call.outputs",
|
||||||
|
"reasoning.encrypted_content",
|
||||||
|
"message.output_text.logprobs",
|
||||||
|
] as const
|
||||||
|
export type ResponseIncludable = (typeof ResponseIncludables)[number]
|
||||||
|
|
||||||
|
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||||
|
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||||
|
|
||||||
|
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||||
|
const INCLUDABLES = new Set<string>(ResponseIncludables)
|
||||||
|
const SERVICE_TIERS = new Set<string>(ServiceTiers)
|
||||||
|
|
||||||
|
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
|
||||||
|
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||||
|
|
||||||
|
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
|
||||||
|
|
||||||
|
export const ReasoningEffort = Schema.String
|
||||||
|
export const TextVerbositySchema = TextVerbosity
|
||||||
|
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
|
||||||
|
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||||
|
|
||||||
|
export interface Resolved {
|
||||||
|
readonly instructions?: string
|
||||||
|
readonly store?: boolean
|
||||||
|
readonly promptCacheKey?: string
|
||||||
|
readonly reasoningEffort?: string
|
||||||
|
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||||
|
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||||
|
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
|
||||||
|
readonly serviceTier?: ServiceTier
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolve = (request: LLMRequest): Resolved => {
|
||||||
|
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
|
||||||
|
const include = Array.isArray(input?.include)
|
||||||
|
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
|
||||||
|
: []
|
||||||
|
const reasoningSummary = input?.reasoningSummary
|
||||||
|
return {
|
||||||
|
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||||
|
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||||
|
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
|
||||||
|
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||||
|
reasoningSummary:
|
||||||
|
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||||
|
? reasoningSummary
|
||||||
|
: undefined,
|
||||||
|
include: include.length > 0 ? include : undefined,
|
||||||
|
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
|
||||||
|
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as OpenResponsesOptions from "./open-responses-options"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
const dimensions = (value: string) => {
|
||||||
|
const match = /^(\d+)x(\d+)$/.exec(value)
|
||||||
|
if (!match) return undefined
|
||||||
|
return { width: Number(match[1]), height: Number(match[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Size = Schema.String.check(
|
||||||
|
Schema.makeFilter((value) => {
|
||||||
|
if (value === "auto") return undefined
|
||||||
|
const parsed = dimensions(value)
|
||||||
|
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
|
||||||
|
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const OpenAIImage = {
|
||||||
|
Size,
|
||||||
|
} as const
|
||||||
@@ -1,85 +1,23 @@
|
|||||||
import { Schema } from "effect"
|
import { ReasoningEfforts } from "../../schema"
|
||||||
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
|
import { OpenResponsesOptions } from "./open-responses-options"
|
||||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
|
||||||
|
|
||||||
export const OpenAIReasoningEfforts = ReasoningEfforts
|
export const OpenAIReasoningEfforts = ReasoningEfforts
|
||||||
export type OpenAIReasoningEffort = string
|
export type OpenAIReasoningEffort = string
|
||||||
|
|
||||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||||
export const OpenAIResponseIncludables = [
|
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||||
"file_search_call.results",
|
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||||
"web_search_call.results",
|
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||||
"web_search_call.action.sources",
|
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||||
"message.input_image.image_url",
|
|
||||||
"computer_call_output.output.image_url",
|
|
||||||
"code_interpreter_call.outputs",
|
|
||||||
"reasoning.encrypted_content",
|
|
||||||
"message.output_text.logprobs",
|
|
||||||
] as const
|
|
||||||
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
|
||||||
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
|
|
||||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
|
|
||||||
|
|
||||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
|
||||||
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
|
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
|
||||||
|
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
|
||||||
export const OpenAIReasoningEffort = Schema.String
|
|
||||||
export const OpenAITextVerbosity = TextVerbosity
|
|
||||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
|
||||||
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
|
|
||||||
|
|
||||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
||||||
|
|
||||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
export const resolve = OpenResponsesOptions.resolve
|
||||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
|
||||||
|
|
||||||
const options = (request: LLMRequest) => request.providerOptions?.openai
|
|
||||||
|
|
||||||
export const store = (request: LLMRequest): boolean | undefined => {
|
|
||||||
const value = options(request)?.store
|
|
||||||
return typeof value === "boolean" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reasoningEffort = (request: LLMRequest): string | undefined => {
|
|
||||||
const value = options(request)?.reasoningEffort
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
|
||||||
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
|
||||||
|
|
||||||
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
|
||||||
// includable values defensively so a typo in upstream config drops the
|
|
||||||
// invalid entry instead of poisoning the wire body. An empty array (either
|
|
||||||
// passed directly or produced by filtering) is treated as "no include" and
|
|
||||||
// returns undefined so the request body omits the field entirely.
|
|
||||||
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
|
|
||||||
const value = options(request)?.include
|
|
||||||
if (!Array.isArray(value)) return undefined
|
|
||||||
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
|
|
||||||
return filtered.length > 0 ? filtered : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const promptCacheKey = (request: LLMRequest) => {
|
|
||||||
const value = options(request)?.promptCacheKey
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const textVerbosity = (request: LLMRequest) => {
|
|
||||||
const value = options(request)?.textVerbosity
|
|
||||||
return isTextVerbosity(value) ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const serviceTier = (request: LLMRequest) => {
|
|
||||||
const value = options(request)?.serviceTier
|
|
||||||
return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const instructions = (request: LLMRequest) => {
|
|
||||||
const value = options(request)?.instructions
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as OpenAIOptions from "./openai-options"
|
export * as OpenAIOptions from "./openai-options"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema"
|
import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema"
|
||||||
import { isRecord } from "../../utils/record"
|
import { isRecord } from "../../utils/record"
|
||||||
import { GeminiToolSchema } from "./gemini-tool-schema"
|
import { GeminiToolSchema } from "./gemini-tool-schema"
|
||||||
|
|
||||||
@@ -63,11 +63,13 @@ const openAI = (schema: JsonSchema): JsonSchema => {
|
|||||||
return isRecord(normalized) ? normalized : { type: "object" }
|
return isRecord(normalized) ? normalized : { type: "object" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responses = openAI
|
||||||
|
|
||||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||||
|
|
||||||
const modelCompatibility = (
|
const modelCompatibility = (
|
||||||
schema: JsonSchema,
|
schema: JsonSchema,
|
||||||
compatibility: ModelToolSchemaCompatibility | undefined,
|
compatibility: LanguageModelToolSchemaCompatibility | undefined,
|
||||||
): JsonSchema => {
|
): JsonSchema => {
|
||||||
if (compatibility === undefined) return schema
|
if (compatibility === undefined) return schema
|
||||||
switch (compatibility) {
|
switch (compatibility) {
|
||||||
@@ -83,4 +85,5 @@ export const ToolSchemaProjection = {
|
|||||||
modelCompatibility,
|
modelCompatibility,
|
||||||
moonshot,
|
moonshot,
|
||||||
openAI,
|
openAI,
|
||||||
|
responses,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
|
|||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
|||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
const raw = inputOverride ?? tool.input
|
||||||
Effect.map(
|
return parseToolInput(route, tool.name, raw).pipe(
|
||||||
(input): ToolCall =>
|
Effect.map((input): ToolCall | ToolInputError =>
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input,
|
input,
|
||||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
tool.providerExecuted
|
||||||
|
? Effect.fail(error)
|
||||||
|
: Effect.succeed(
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
raw,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||||
|
event.type === "tool-input-error"
|
||||||
|
? [event]
|
||||||
|
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||||
|
|
||||||
/** Store the updated tool and produce the optional public delta event. */
|
/** Store the updated tool and produce the optional public delta event. */
|
||||||
const appendTool = <K extends StreamKey>(
|
const appendTool = <K extends StreamKey>(
|
||||||
@@ -94,8 +112,8 @@ const appendTool = <K extends StreamKey>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | AIError): result is AIError =>
|
||||||
result instanceof LLMError
|
result instanceof AIError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a tool call whose start event arrived before any argument deltas.
|
* Register a tool call whose start event arrived before any argument deltas.
|
||||||
@@ -120,10 +138,10 @@ export const appendOrStart = <K extends StreamKey>(
|
|||||||
key: K,
|
key: K,
|
||||||
delta: { readonly id?: string; readonly name?: string; readonly text: string },
|
delta: { readonly id?: string; readonly name?: string; readonly text: string },
|
||||||
missingToolMessage: string,
|
missingToolMessage: string,
|
||||||
): AppendOutcome<K> | LLMError => {
|
): AppendOutcome<K> | AIError => {
|
||||||
const current = tools[key]
|
const current = tools[key]
|
||||||
const id = delta.id ?? current?.id
|
const id = current?.id ?? delta.id
|
||||||
const name = delta.name ?? current?.name
|
const name = current?.name ?? delta.name
|
||||||
if (!id || !name) return eventError(route, missingToolMessage)
|
if (!id || !name) return eventError(route, missingToolMessage)
|
||||||
|
|
||||||
const tool = {
|
const tool = {
|
||||||
@@ -149,7 +167,7 @@ export const appendExisting = <K extends StreamKey>(
|
|||||||
key: K,
|
key: K,
|
||||||
text: string,
|
text: string,
|
||||||
missingToolMessage: string,
|
missingToolMessage: string,
|
||||||
): AppendOutcome<K> | LLMError => {
|
): AppendOutcome<K> | AIError => {
|
||||||
const current = tools[key]
|
const current = tools[key]
|
||||||
if (!current) return eventError(route, missingToolMessage)
|
if (!current) return eventError(route, missingToolMessage)
|
||||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||||
@@ -158,8 +176,9 @@ export const appendExisting = <K extends StreamKey>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||||
* from state, and return the optional public `tool-call` event. Missing keys are
|
* from state, and return either a call or a non-executable local input error.
|
||||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
* Missing keys are a no-op because some providers emit stop events for
|
||||||
|
* non-tool content blocks.
|
||||||
*/
|
*/
|
||||||
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -167,10 +186,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
|||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
|||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool, input)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool, input),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
||||||
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
* not emit per-tool stop events, so all accumulated calls finish independently
|
||||||
* receives a terminal `finish_reason`.
|
* when the choice receives a terminal `finish_reason`.
|
||||||
*/
|
*/
|
||||||
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
|||||||
return {
|
return {
|
||||||
tools: empty<K>(),
|
tools: empty<K>(),
|
||||||
events: yield* Effect.forEach(pending, (tool) =>
|
events: yield* Effect.forEach(pending, (tool) =>
|
||||||
toolCall(route, tool).pipe(
|
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
|
||||||
Effect.map((call) => [
|
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
call,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
).pipe(Effect.map((events) => events.flat())),
|
).pipe(Effect.map((events) => events.flat())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
AIError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared, optionalNull } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "xai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
|
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type XAIImageOptions = {
|
||||||
|
readonly n?: number
|
||||||
|
readonly aspectRatio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly aspect_ratio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly resolution?: XAIImageString<"1k" | "2k">
|
||||||
|
readonly responseFormat?: XAIImageString<"url" | "b64_json">
|
||||||
|
readonly response_format?: XAIImageString<"url" | "b64_json">
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type XAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const XAIImageResponse = Schema.Struct({
|
||||||
|
data: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
b64_json: optionalNull(Schema.String),
|
||||||
|
url: optionalNull(Schema.String),
|
||||||
|
revised_prompt: optionalNull(Schema.String),
|
||||||
|
mime_type: optionalNull(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
usage: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { aspectRatio, responseFormat, ...native } = options
|
||||||
|
return {
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
response_format: responseFormat,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new AIError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<XAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const imageReferences = (request.images ?? []).map((image) => {
|
||||||
|
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
|
||||||
|
if (image.type === "url") return { url: image.url, type: "image_url" as const }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (imageReferences.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
|
||||||
|
images: imageReferences.length > 1 ? imageReferences : undefined,
|
||||||
|
},
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as XAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||||
|
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
|
||||||
|
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import { InvalidProviderOutputReason, AIError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "zai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type ZAIImageOptions = {
|
||||||
|
readonly size?: ZAIImageString<
|
||||||
|
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||||
|
>
|
||||||
|
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||||
|
readonly userID?: string
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type ZAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZAIImageResponse = Schema.Struct({
|
||||||
|
created: Schema.optional(Schema.Int),
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
request_id: Schema.optional(Schema.String),
|
||||||
|
data: Schema.Array(Schema.Struct({ url: Schema.String })),
|
||||||
|
content_filter: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.optional(Schema.String),
|
||||||
|
level: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { userID, ...native } = options
|
||||||
|
return {
|
||||||
|
user_id: userID,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new AIError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<ZAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||||
|
if ((request.images?.length ?? 0) > 0)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as ZAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images: decoded.data.map(
|
||||||
|
(item) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
data: item.url,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
providerMetadata: {
|
||||||
|
zai: {
|
||||||
|
created: decoded.created,
|
||||||
|
id: decoded.id,
|
||||||
|
requestID: decoded.request_id,
|
||||||
|
contentFilter: decoded.content_filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
AuthenticationReason,
|
AuthenticationReason,
|
||||||
ContentPolicyReason,
|
ContentPolicyReason,
|
||||||
InvalidRequestReason,
|
InvalidRequestReason,
|
||||||
LLMError,
|
AIError,
|
||||||
ProviderErrorEvent,
|
ProviderErrorEvent,
|
||||||
ProviderInternalReason,
|
ProviderInternalReason,
|
||||||
QuotaExceededReason,
|
QuotaExceededReason,
|
||||||
@@ -18,30 +18,42 @@ const patterns = [
|
|||||||
/prompt is too long/i,
|
/prompt is too long/i,
|
||||||
/input is too long for requested model/i,
|
/input is too long for requested model/i,
|
||||||
/exceeds the context window/i,
|
/exceeds the context window/i,
|
||||||
|
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||||
/input token count.*exceeds the maximum/i,
|
/input token count.*exceeds the maximum/i,
|
||||||
/tokens in request more than max tokens allowed/i,
|
/tokens in request more than max tokens allowed/i,
|
||||||
/maximum prompt length is \d+/i,
|
/maximum prompt length is \d+/i,
|
||||||
/reduce the length of the messages/i,
|
/reduce the length of the messages/i,
|
||||||
/maximum context length is \d+ tokens/i,
|
/maximum context length is \d+ tokens/i,
|
||||||
|
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
||||||
|
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
||||||
/exceeds the limit of \d+/i,
|
/exceeds the limit of \d+/i,
|
||||||
/exceeds the available context size/i,
|
/exceeds the available context size/i,
|
||||||
/greater than the context length/i,
|
/greater than the context length/i,
|
||||||
/context window exceeds limit/i,
|
/context window exceeds limit/i,
|
||||||
/exceeded model token limit/i,
|
/exceeded model token limit/i,
|
||||||
/context[_ ]length[_ ]exceeded/i,
|
/context[_ ]length[_ ]exceeded/i,
|
||||||
/request entity too large/i,
|
|
||||||
/context length is only \d+ tokens/i,
|
/context length is only \d+ tokens/i,
|
||||||
/input length.*exceeds.*context length/i,
|
/input length.*exceeds.*context length/i,
|
||||||
/prompt too long; exceeded (?:max )?context length/i,
|
/prompt too long; exceeded (?:max )?context length/i,
|
||||||
/too large for model with \d+ maximum context length/i,
|
/too large for model with \d+ maximum context length/i,
|
||||||
|
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||||
/model_context_window_exceeded/i,
|
/model_context_window_exceeded/i,
|
||||||
|
/too many tokens/i,
|
||||||
|
/token limit exceeded/i,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||||
|
|
||||||
|
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||||
|
|
||||||
export const isContextOverflow = (message: string) =>
|
export const isContextOverflow = (message: string) =>
|
||||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||||
|
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||||
|
|
||||||
|
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||||
|
|
||||||
export const isContextOverflowFailure = (failure: unknown) =>
|
export const isContextOverflowFailure = (failure: unknown) =>
|
||||||
failure instanceof LLMError
|
failure instanceof AIError
|
||||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||||
|
|
||||||
@@ -74,7 +86,7 @@ export interface ProviderFailure {
|
|||||||
|
|
||||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||||
// session retry policy never needs provider-specific string matching.
|
// session retry policy never needs provider-specific string matching.
|
||||||
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
|
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||||
const body = input.http?.body ?? ""
|
const body = input.http?.body ?? ""
|
||||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||||
.filter((code): code is string => code !== undefined)
|
.filter((code): code is string => code !== undefined)
|
||||||
@@ -90,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
isContextOverflow(text))
|
isContextOverflow(text))
|
||||||
)
|
)
|
||||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||||
|
if (input.status === 413 || isPayloadTooLarge(text))
|
||||||
|
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||||
return new QuotaExceededReason(common)
|
return new QuotaExceededReason(common)
|
||||||
@@ -125,20 +139,14 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (input.status !== undefined && input.status >= 500)
|
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||||
return new ProviderInternalReason({
|
return new ProviderInternalReason({
|
||||||
...common,
|
...common,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
retryAfterMs: input.retryAfterMs,
|
retryAfterMs: input.retryAfterMs,
|
||||||
})
|
})
|
||||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||||
if (
|
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||||
input.status === 400 ||
|
|
||||||
input.status === 404 ||
|
|
||||||
input.status === 409 ||
|
|
||||||
input.status === 413 ||
|
|
||||||
input.status === 422
|
|
||||||
)
|
|
||||||
return new InvalidRequestReason(common)
|
return new InvalidRequestReason(common)
|
||||||
return new UnknownProviderReason({ ...common, status: input.status })
|
return new UnknownProviderReason({ ...common, status: input.status })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { Model } from "./schema"
|
import type { LanguageModel, ProviderOptions } from "./schema"
|
||||||
|
|
||||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||||
|
readonly baseURL?: string
|
||||||
readonly headers?: Readonly<Record<string, string>>
|
readonly headers?: Readonly<Record<string, string>>
|
||||||
readonly body?: Readonly<Record<string, unknown>>
|
readonly body?: Readonly<Record<string, unknown>>
|
||||||
readonly limits?: {
|
readonly limits?: {
|
||||||
readonly context: number
|
readonly context: number
|
||||||
|
readonly input?: number
|
||||||
readonly output: number
|
readonly output: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Definition<ProviderSettings extends Settings = Settings> {
|
export interface Definition<
|
||||||
readonly model: (modelID: string, settings: ProviderSettings) => Model
|
ProviderSettings extends Settings = Settings,
|
||||||
|
Options extends ProviderOptions = ProviderOptions,
|
||||||
|
> {
|
||||||
|
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as ProviderPackage from "./provider-package"
|
export * as ProviderPackage from "./provider-package"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Model, ModelID, ProviderID } from "./schema"
|
import type { LanguageModel, ModelID, ProviderID } from "./schema"
|
||||||
|
|
||||||
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
|
export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compatibility">
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced structural provider definition helper. Built-in providers should
|
* Advanced structural provider definition helper. Built-in providers should
|
||||||
@@ -8,23 +8,23 @@ export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
|
|||||||
* chosen before model selection. The optional `apis` map remains for external
|
* chosen before model selection. The optional `apis` map remains for external
|
||||||
* structural providers that expose multiple route selectors behind one provider.
|
* structural providers that expose multiple route selectors behind one provider.
|
||||||
*/
|
*/
|
||||||
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
|
export type LanguageModelFactory<Options extends LanguageModelOptions = LanguageModelOptions> = (
|
||||||
id: string | ModelID,
|
id: string | ModelID,
|
||||||
options?: Options,
|
options?: Options,
|
||||||
) => Model
|
) => LanguageModel
|
||||||
|
|
||||||
type AnyModelFactory = (...args: never[]) => Model
|
type AnyLanguageModelFactory = (...args: never[]) => LanguageModel
|
||||||
|
|
||||||
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
|
export interface Definition<Factory extends AnyLanguageModelFactory = LanguageModelFactory> {
|
||||||
readonly id: ProviderID
|
readonly id: ProviderID
|
||||||
readonly model: Factory
|
readonly model: Factory
|
||||||
readonly apis?: Record<string, AnyModelFactory>
|
readonly apis?: Record<string, AnyLanguageModelFactory>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefinitionShape = {
|
type DefinitionShape = {
|
||||||
readonly id: ProviderID
|
readonly id: ProviderID
|
||||||
readonly model: (...args: never[]) => Model
|
readonly model: (...args: never[]) => LanguageModel
|
||||||
readonly apis?: Record<string, (...args: never[]) => Model>
|
readonly apis?: Record<string, (...args: never[]) => LanguageModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
|
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Auth } from "../route/auth"
|
||||||
|
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
|
||||||
|
import type { ProviderPackage } from "../provider-package"
|
||||||
|
import { OpenAIChat } from "../protocols/openai-chat"
|
||||||
|
import { OpenAIResponses } from "../protocols/openai-responses"
|
||||||
|
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
|
||||||
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("amazon-bedrock")
|
||||||
|
|
||||||
|
export type Config = RouteDefaultsInput & {
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly credentials?: Credentials
|
||||||
|
readonly region?: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly auth?: "bearer" | "sigv4"
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly credentials?: Credentials
|
||||||
|
readonly region?: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsesRoute = OpenAIResponses.route.with({
|
||||||
|
id: "bedrock-mantle-responses",
|
||||||
|
provider: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatRoute = OpenAIChat.route.with({
|
||||||
|
id: "bedrock-mantle-chat",
|
||||||
|
provider: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [responsesRoute, chatRoute]
|
||||||
|
|
||||||
|
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
|
||||||
|
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||||
|
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||||
|
return route.with({
|
||||||
|
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||||
|
auth:
|
||||||
|
input.apiKey === undefined
|
||||||
|
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||||
|
: Auth.bearer(input.apiKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = (input: Config) => {
|
||||||
|
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||||
|
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||||
|
const modelDefaults = defaults(input)
|
||||||
|
const responses = (modelID: string | ModelID) =>
|
||||||
|
configuredResponsesRoute
|
||||||
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
const chat = (modelID: string | ModelID) =>
|
||||||
|
configuredChatRoute
|
||||||
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
model: chat,
|
||||||
|
chat,
|
||||||
|
responses,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = configure()
|
||||||
|
|
||||||
|
const config = (settings: Settings): Config => {
|
||||||
|
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||||
|
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||||
|
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||||
|
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||||
|
return {
|
||||||
|
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
credentials: settings.credentials,
|
||||||
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
region: settings.region,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).chat(modelID)
|
||||||
|
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).responses(modelID)
|
||||||
|
export const model = chatModel
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { chatModel as model } from "../amazon-bedrock-mantle"
|
||||||
|
export type { Settings } from "../amazon-bedrock-mantle"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { chatModel as model } from "../../amazon-bedrock-mantle"
|
||||||
|
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { responsesModel as model } from "../../amazon-bedrock-mantle"
|
||||||
|
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||||
@@ -5,12 +5,17 @@ import type { ProviderAuthOption } from "../route/auth-options"
|
|||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
|
|
||||||
|
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||||
|
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||||
|
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||||
|
|
||||||
export const id = ProviderID.make("anthropic-compatible")
|
export const id = ProviderID.make("anthropic-compatible")
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput &
|
export type Config = RouteDefaultsInput &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Settings = ProviderPackage.Settings &
|
export type Settings = ProviderPackage.Settings &
|
||||||
@@ -20,6 +25,7 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
) & {
|
) & {
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [AnthropicMessages.route]
|
export const routes = [AnthropicMessages.route]
|
||||||
@@ -41,7 +47,7 @@ export const configure = (input: Config) => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: ProviderID.make(provider),
|
id: ProviderID.make(provider),
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +57,10 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||||
return configure({
|
return configure({
|
||||||
@@ -61,6 +70,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
|||||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ import { ProviderID, type ModelID } from "../schema"
|
|||||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||||
import { AnthropicCompatible } from "./anthropic-compatible"
|
import { AnthropicCompatible } from "./anthropic-compatible"
|
||||||
|
|
||||||
|
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||||
|
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||||
|
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||||
|
|
||||||
export const id = ProviderID.make("anthropic")
|
export const id = ProviderID.make("anthropic")
|
||||||
|
|
||||||
export const routes = [AnthropicMessages.route]
|
export const routes = [AnthropicMessages.route]
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
export type Config = RouteDefaultsInput &
|
||||||
|
ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
export type Settings = ProviderPackage.Settings &
|
export type Settings = ProviderPackage.Settings &
|
||||||
(
|
(
|
||||||
@@ -18,6 +26,7 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
| { readonly apiKey?: never; readonly authToken?: string }
|
| { readonly apiKey?: never; readonly authToken?: string }
|
||||||
) & {
|
) & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||||
@@ -43,7 +52,10 @@ export const configure = (input: Config = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||||
return configure({
|
return configure({
|
||||||
@@ -52,5 +64,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
|||||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const routeAuth = Auth.remove("authorization")
|
|||||||
// (helper builds the URL) or `baseURL` directly.
|
// (helper builds the URL) or `baseURL` directly.
|
||||||
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
|
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
|
||||||
|
|
||||||
export type ModelOptions = AzureURL &
|
export type LanguageModelOptions = AzureURL &
|
||||||
RouteDefaultsInput &
|
RouteDefaultsInput &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly apiVersion?: string
|
readonly apiVersion?: string
|
||||||
@@ -22,7 +22,7 @@ export type ModelOptions = AzureURL &
|
|||||||
readonly useCompletionUrls?: boolean
|
readonly useCompletionUrls?: boolean
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
export type Config = ModelOptions
|
export type Config = LanguageModelOptions
|
||||||
|
|
||||||
export type Settings = ProviderPackage.Settings &
|
export type Settings = ProviderPackage.Settings &
|
||||||
AzureURL & {
|
AzureURL & {
|
||||||
@@ -99,10 +99,14 @@ export const configure = (input: Config) => {
|
|||||||
const modelDefaults = defaults(input)
|
const modelDefaults = defaults(input)
|
||||||
|
|
||||||
const responses = (modelID: string | ModelID) =>
|
const responses = (modelID: string | ModelID) =>
|
||||||
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
configuredResponsesRoute
|
||||||
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
const chat = (modelID: string | ModelID) =>
|
const chat = (modelID: string | ModelID) =>
|
||||||
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
configuredChatRoute
|
||||||
|
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -133,8 +137,12 @@ const config = (settings: Settings): Config => {
|
|||||||
throw new Error("Azure requires resourceName or baseURL")
|
throw new Error("Azure requires resourceName or baseURL")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
configure(config(settings)).responses(modelID)
|
modelID,
|
||||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
settings,
|
||||||
configure(config(settings)).chat(modelID)
|
) => configure(config(settings)).responses(modelID)
|
||||||
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const model = responsesModel
|
export const model = responsesModel
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Auth } from "../route/auth"
|
|||||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
|
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
|
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
|
||||||
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
|
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
|
||||||
@@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AIGatewayOptions = GatewayURL &
|
export type AIGatewayOptions = GatewayURL &
|
||||||
RouteDefaultsInput &
|
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||||
readonly gatewayApiKey?: CloudflareSecret
|
readonly gatewayApiKey?: CloudflareSecret
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkersAIURL = AtLeastOne<{
|
type WorkersAIURL = AtLeastOne<{
|
||||||
@@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{
|
|||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
|
export type WorkersAIOptions = WorkersAIURL &
|
||||||
|
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
|
ProviderAuthOption<"optional"> & {
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
export const aiGatewayBaseURL = (input: GatewayURL) => {
|
export const aiGatewayBaseURL = (input: GatewayURL) => {
|
||||||
if (input.baseURL) return input.baseURL
|
if (input.baseURL) return input.baseURL
|
||||||
@@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: aiGatewayID,
|
id: aiGatewayID,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||||
configure: configureAIGateway,
|
configure: configureAIGateway,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: workersAIID,
|
id: workersAIID,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||||
configure: configureWorkersAI,
|
configure: configureWorkersAI,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export const id = ProviderID.make("github-copilot")
|
|||||||
|
|
||||||
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
|
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
|
||||||
// supply `baseURL` explicitly.
|
// supply `baseURL` explicitly.
|
||||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
readonly endpoint?: "chat" | "responses"
|
readonly endpoint?: "chat" | "responses"
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => {
|
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
|
||||||
if (endpoint) return endpoint === "responses"
|
if (endpoint) return endpoint === "responses"
|
||||||
const model = String(modelID)
|
const model = String(modelID)
|
||||||
const match = /^gpt-(\d+)/.exec(model)
|
const match = /^gpt-(\d+)/.exec(model)
|
||||||
@@ -29,30 +29,32 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
|
|||||||
const chatRoute = OpenAIChat.route.with({ provider: id })
|
const chatRoute = OpenAIChat.route.with({ provider: id })
|
||||||
const responsesRoute = OpenAIResponses.route.with({ provider: id })
|
const responsesRoute = OpenAIResponses.route.with({ provider: id })
|
||||||
|
|
||||||
const defaults = (options: ModelOptions) => {
|
const defaults = (options: LanguageModelOptions) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
|
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredResponsesRoute = (options: ModelOptions) =>
|
const configuredResponsesRoute = (options: LanguageModelOptions) =>
|
||||||
responsesRoute.with({
|
responsesRoute.with({
|
||||||
endpoint: { baseURL: options.baseURL },
|
endpoint: { baseURL: options.baseURL },
|
||||||
auth: AuthOptions.bearer(options, []),
|
auth: AuthOptions.bearer(options, []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const configuredChatRoute = (options: ModelOptions) =>
|
const configuredChatRoute = (options: LanguageModelOptions) =>
|
||||||
chatRoute.with({
|
chatRoute.with({
|
||||||
endpoint: { baseURL: options.baseURL },
|
endpoint: { baseURL: options.baseURL },
|
||||||
auth: AuthOptions.bearer(options, []),
|
auth: AuthOptions.bearer(options, []),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const configure = (options: ModelOptions) => {
|
export const configure = (options: LanguageModelOptions) => {
|
||||||
const responsesRoute = configuredResponsesRoute(options)
|
const responsesRoute = configuredResponsesRoute(options)
|
||||||
const chatRoute = configuredChatRoute(options)
|
const chatRoute = configuredChatRoute(options)
|
||||||
const responses = (modelID: string | ModelID) =>
|
const responses = (modelID: string | ModelID) =>
|
||||||
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
responsesRoute
|
||||||
|
.with(withOpenAIOptions(modelID, defaults(options)))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
const chat = (modelID: string | ModelID) =>
|
const chat = (modelID: string | ModelID) =>
|
||||||
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) =>
|
model: (modelID: string | ModelID) =>
|
||||||
|
|||||||
+18
-12
@@ -1,16 +1,18 @@
|
|||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { GoogleVertexAnthropic } from "../protocols/google-vertex-anthropic"
|
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||||
|
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export const id = ProviderID.make("google-vertex-anthropic")
|
export const id = ProviderID.make("google-vertex")
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput &
|
export type Config = RouteDefaultsInput &
|
||||||
GoogleVertexShared.OAuthOptions & {
|
GoogleVertexShared.OAuthOptions & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
@@ -19,14 +21,18 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [GoogleVertexAnthropic.route]
|
const route = OpenAICompatibleChat.route.with({
|
||||||
|
id: "google-vertex-chat",
|
||||||
|
provider: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [route]
|
||||||
|
|
||||||
const configuredRoute = (input: Config) => {
|
const configuredRoute = (input: Config) => {
|
||||||
if ("apiKey" in input && input.apiKey !== undefined)
|
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||||
throw new Error("Google Vertex Anthropic does not support API keys")
|
|
||||||
const {
|
const {
|
||||||
accessToken: _accessToken,
|
accessToken: _accessToken,
|
||||||
auth: _auth,
|
auth: _auth,
|
||||||
@@ -37,12 +43,12 @@ const configuredRoute = (input: Config) => {
|
|||||||
} = input
|
} = input
|
||||||
const location = GoogleVertexShared.location(inputLocation, "global")
|
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||||
const project = GoogleVertexShared.project(inputProject)
|
const project = GoogleVertexShared.project(inputProject)
|
||||||
return GoogleVertexAnthropic.route.with({
|
return route.with({
|
||||||
...rest,
|
...rest,
|
||||||
endpoint: {
|
endpoint: {
|
||||||
baseURL:
|
baseURL:
|
||||||
baseURL ??
|
baseURL ??
|
||||||
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
|
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
|
||||||
},
|
},
|
||||||
auth: GoogleVertexShared.oauth(input, project),
|
auth: GoogleVertexShared.oauth(input, project),
|
||||||
})
|
})
|
||||||
@@ -52,7 +58,7 @@ export const configure = (input: Config = {}) => {
|
|||||||
const route = configuredRoute(input)
|
const route = configuredRoute(input)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,8 +68,8 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Anthropic does not support API keys")
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||||
return configure({
|
return configure({
|
||||||
accessToken: settings.accessToken,
|
accessToken: settings.accessToken,
|
||||||
baseURL: settings.baseURL,
|
baseURL: settings.baseURL,
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Effect, Schema, Struct } from "effect"
|
||||||
|
import type { ProviderPackage } from "../provider-package"
|
||||||
|
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||||
|
import { Auth } from "../route/auth"
|
||||||
|
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||||
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
import { Framing } from "../route/framing"
|
||||||
|
import { Protocol } from "../route/protocol"
|
||||||
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
|
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||||
|
|
||||||
|
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||||
|
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||||
|
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||||
|
|
||||||
|
const VERSION = "vertex-2023-10-16" as const
|
||||||
|
|
||||||
|
// models.dev uses this provider id even though the API contract is Anthropic Messages.
|
||||||
|
export const id = ProviderID.make("google-vertex-anthropic")
|
||||||
|
|
||||||
|
export type Config = RouteDefaultsInput &
|
||||||
|
GoogleVertexShared.OAuthOptions & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly location?: string
|
||||||
|
readonly project?: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
|
readonly accessToken?: string
|
||||||
|
readonly apiKey?: never
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly location?: string
|
||||||
|
readonly project?: string
|
||||||
|
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = Route.make({
|
||||||
|
id: "google-vertex-messages",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "anthropic",
|
||||||
|
protocol: Protocol.make({
|
||||||
|
id: AnthropicMessages.protocol.id,
|
||||||
|
body: {
|
||||||
|
schema: Schema.Struct({
|
||||||
|
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
|
||||||
|
anthropic_version: Schema.Literal(VERSION),
|
||||||
|
}),
|
||||||
|
from: (request) =>
|
||||||
|
AnthropicMessages.protocol.body.from(request).pipe(
|
||||||
|
Effect.map((body) => ({
|
||||||
|
...Struct.omit(body, ["model"]),
|
||||||
|
anthropic_version: VERSION,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
stream: AnthropicMessages.protocol.stream,
|
||||||
|
}),
|
||||||
|
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||||
|
auth: Auth.none,
|
||||||
|
framing: Framing.sse,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [route]
|
||||||
|
|
||||||
|
const configuredRoute = (input: Config) => {
|
||||||
|
if ("apiKey" in input && input.apiKey !== undefined)
|
||||||
|
throw new Error("Google Vertex Messages does not support API keys")
|
||||||
|
const {
|
||||||
|
accessToken: _accessToken,
|
||||||
|
auth: _auth,
|
||||||
|
baseURL,
|
||||||
|
location: inputLocation,
|
||||||
|
project: inputProject,
|
||||||
|
...rest
|
||||||
|
} = input
|
||||||
|
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||||
|
const project = GoogleVertexShared.project(inputProject)
|
||||||
|
return route.with({
|
||||||
|
...rest,
|
||||||
|
endpoint: {
|
||||||
|
baseURL:
|
||||||
|
baseURL ??
|
||||||
|
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
|
||||||
|
},
|
||||||
|
auth: GoogleVertexShared.oauth(input, project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const route = configuredRoute(input)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = {
|
||||||
|
id,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||||
|
return configure({
|
||||||
|
accessToken: settings.accessToken,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
location: settings.location,
|
||||||
|
project: settings.project,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { ProviderPackage } from "../provider-package"
|
||||||
|
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
|
||||||
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
|
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||||
|
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("google-vertex")
|
||||||
|
|
||||||
|
export type Config = RouteDefaultsInput &
|
||||||
|
GoogleVertexShared.OAuthOptions & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly location?: string
|
||||||
|
readonly project?: string
|
||||||
|
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
|
readonly accessToken?: string
|
||||||
|
readonly apiKey?: never
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly location?: string
|
||||||
|
readonly project?: string
|
||||||
|
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = OpenAICompatibleResponses.route.with({
|
||||||
|
id: "google-vertex-responses",
|
||||||
|
provider: id,
|
||||||
|
providerOptions: { openresponses: { store: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [route]
|
||||||
|
|
||||||
|
const configuredRoute = (input: Config) => {
|
||||||
|
if ("apiKey" in input && input.apiKey !== undefined)
|
||||||
|
throw new Error("Google Vertex Responses does not support API keys")
|
||||||
|
const {
|
||||||
|
accessToken: _accessToken,
|
||||||
|
auth: _auth,
|
||||||
|
baseURL,
|
||||||
|
location: inputLocation,
|
||||||
|
project: inputProject,
|
||||||
|
...rest
|
||||||
|
} = input
|
||||||
|
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||||
|
const project = GoogleVertexShared.project(inputProject)
|
||||||
|
return route.with({
|
||||||
|
...rest,
|
||||||
|
endpoint: {
|
||||||
|
baseURL:
|
||||||
|
baseURL ??
|
||||||
|
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
|
||||||
|
},
|
||||||
|
auth: GoogleVertexShared.oauth(input, project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const route = configuredRoute(input)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = {
|
||||||
|
id,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||||
|
return configure({
|
||||||
|
accessToken: settings.accessToken,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
location: settings.location,
|
||||||
|
project: settings.project,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { GoogleVertexGemini } from "../protocols/google-vertex-gemini"
|
import { Gemini } from "../protocols/gemini"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
import { Framing } from "../route/framing"
|
||||||
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||||
|
|
||||||
|
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||||
|
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||||
|
|
||||||
export const id = ProviderID.make("google-vertex")
|
export const id = ProviderID.make("google-vertex")
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput &
|
export type Config = RouteDefaultsInput &
|
||||||
@@ -12,6 +17,7 @@ export type Config = RouteDefaultsInput &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
|
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Settings = ProviderPackage.Settings &
|
export type Settings = ProviderPackage.Settings &
|
||||||
@@ -22,10 +28,23 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [GoogleVertexGemini.route]
|
const route = Route.make({
|
||||||
|
id: "google-vertex-gemini",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "google",
|
||||||
|
protocol: Gemini.protocol,
|
||||||
|
endpoint: Endpoint.path(({ request }) => {
|
||||||
|
const model = String(request.model.id)
|
||||||
|
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||||
|
}),
|
||||||
|
auth: Auth.none,
|
||||||
|
framing: Framing.sse,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [route]
|
||||||
|
|
||||||
const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||||
const {
|
const {
|
||||||
@@ -48,7 +67,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
|||||||
(apiKey
|
(apiKey
|
||||||
? "https://aiplatform.googleapis.com/v1/publishers/google"
|
? "https://aiplatform.googleapis.com/v1/publishers/google"
|
||||||
: `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`)
|
: `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`)
|
||||||
return GoogleVertexGemini.route.with({
|
return route.with({
|
||||||
...rest,
|
...rest,
|
||||||
endpoint: { baseURL: endpoint },
|
endpoint: { baseURL: endpoint },
|
||||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||||
@@ -58,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
|||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
|
model: (modelID: string | ModelID) =>
|
||||||
|
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +87,10 @@ export const provider = {
|
|||||||
id,
|
id,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||||
return configure({
|
return configure({
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export { model } from "../google-vertex-anthropic"
|
|
||||||
export type { Settings } from "../google-vertex-anthropic"
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { model } from "../google-vertex-chat"
|
||||||
|
export type { Settings } from "../google-vertex-chat"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { model } from "../google-vertex"
|
||||||
|
export type { Settings } from "../google-vertex"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { model } from "../google-vertex-messages"
|
||||||
|
export type { Settings } from "../google-vertex-messages"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { model } from "../google-vertex-responses"
|
||||||
|
export type { Settings } from "../google-vertex-responses"
|
||||||
@@ -2,19 +2,28 @@ import type { RouteDefaultsInput } from "../route/client"
|
|||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options"
|
import type { ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema"
|
||||||
import * as Gemini from "../protocols/gemini"
|
import { Gemini } from "../protocols/gemini"
|
||||||
|
import { GoogleImages } from "../protocols/google-images"
|
||||||
|
|
||||||
|
export type { GoogleImageOptions } from "../protocols/google-images"
|
||||||
|
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||||
|
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||||
|
|
||||||
export const id = ProviderID.make("google")
|
export const id = ProviderID.make("google")
|
||||||
|
|
||||||
export const routes = [Gemini.route]
|
export const routes = [Gemini.route]
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
export type Config = RouteDefaultsInput &
|
||||||
|
ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||||
@@ -31,15 +40,24 @@ const configuredRoute = (input: Config) => {
|
|||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const route = configuredRoute(input)
|
const route = configuredRoute(input)
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
GoogleImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
configure({
|
configure({
|
||||||
apiKey: settings.apiKey,
|
apiKey: settings.apiKey,
|
||||||
baseURL: settings.baseURL,
|
baseURL: settings.baseURL,
|
||||||
@@ -48,3 +66,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
|||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
providerOptions: settings.providerOptions,
|
providerOptions: settings.providerOptions,
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
|
|
||||||
|
export const image = provider.image
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
export * as Anthropic from "./anthropic"
|
export * as Anthropic from "./anthropic"
|
||||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||||
export * as AmazonBedrock from "./amazon-bedrock"
|
export * as AmazonBedrock from "./amazon-bedrock"
|
||||||
|
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
|
||||||
export * as Azure from "./azure"
|
export * as Azure from "./azure"
|
||||||
export * as Cloudflare from "./cloudflare"
|
export * as Cloudflare from "./cloudflare"
|
||||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||||
export * as GitHubCopilot from "./github-copilot"
|
export * as GitHubCopilot from "./github-copilot"
|
||||||
export * as Google from "./google"
|
export * as Google from "./google"
|
||||||
export * as GoogleVertex from "./google-vertex"
|
export * as GoogleVertex from "./google-vertex"
|
||||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
export * as GoogleVertexChat from "./google-vertex-chat"
|
||||||
|
export * as GoogleVertexMessages from "./google-vertex-messages"
|
||||||
|
export * as GoogleVertexResponses from "./google-vertex-responses"
|
||||||
export * as OpenAI from "./openai"
|
export * as OpenAI from "./openai"
|
||||||
export * as OpenAICompatible from "./openai-compatible"
|
export * as OpenAICompatible from "./openai-compatible"
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenRouter from "./openrouter"
|
export * as OpenRouter from "./openrouter"
|
||||||
export * as XAI from "./xai"
|
export * as XAI from "./xai"
|
||||||
|
export * as ZAI from "./zai"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options"
|
||||||
|
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||||
|
|
||||||
|
export interface OpenResponsesOptionsInput {
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
readonly instructions?: string
|
||||||
|
readonly store?: boolean
|
||||||
|
readonly promptCacheKey?: string
|
||||||
|
readonly reasoningEffort?: ReasoningEffort
|
||||||
|
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||||
|
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||||
|
readonly textVerbosity?: TextVerbosity
|
||||||
|
readonly serviceTier?: ServiceTier
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
|
||||||
|
readonly openresponses?: OpenResponsesOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as OpenResponsesProviderOptions from "./open-responses-options"
|
||||||
@@ -3,7 +3,9 @@ import { OpenAICompatibleResponses } from "../protocols/openai-compatible-respon
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { ProviderID, type ModelID } from "../schema"
|
||||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||||
|
|
||||||
|
export type { OpenResponsesOptionsInput, OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai-compatible")
|
export const id = ProviderID.make("openai-compatible")
|
||||||
|
|
||||||
@@ -11,13 +13,14 @@ export type Config = RouteDefaultsInput &
|
|||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
|
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [OpenAICompatibleResponses.route]
|
export const routes = [OpenAICompatibleResponses.route]
|
||||||
@@ -33,7 +36,7 @@ export const configure = (input: Config) => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: ProviderID.make(provider),
|
id: ProviderID.make(provider),
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,10 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) =>
|
||||||
configure({
|
configure({
|
||||||
apiKey: settings.apiKey,
|
apiKey: settings.apiKey,
|
||||||
baseURL: settings.baseURL,
|
baseURL: settings.baseURL,
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import type { RouteDefaultsInput } from "../route/client"
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
|
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
|
||||||
|
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai-compatible")
|
export const id = ProviderID.make("openai-compatible")
|
||||||
|
|
||||||
type GenericModelOptions = RouteDefaultsInput &
|
type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
@@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FamilyModelOptions = RouteDefaultsInput &
|
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [OpenAICompatibleChat.route]
|
export const routes = [OpenAICompatibleChat.route]
|
||||||
@@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: ProviderID.make(provider),
|
id: ProviderID.make(provider),
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
|
model: (modelID: string | ModelID) =>
|
||||||
|
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +67,7 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
configure({
|
configure({
|
||||||
apiKey: settings.apiKey,
|
apiKey: settings.apiKey,
|
||||||
baseURL: settings.baseURL,
|
baseURL: settings.baseURL,
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
import type { ProviderOptions } from "../schema"
|
||||||
import { mergeProviderOptions } from "../schema"
|
import { mergeProviderOptions } from "../schema"
|
||||||
import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
import type { OpenResponsesOptionsInput } from "./open-responses-options"
|
||||||
|
|
||||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options"
|
||||||
|
|
||||||
export interface OpenAIOptionsInput {
|
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||||
readonly [key: string]: unknown
|
|
||||||
readonly store?: boolean
|
|
||||||
readonly promptCacheKey?: string
|
|
||||||
readonly reasoningEffort?: ReasoningEffort
|
|
||||||
readonly reasoningSummary?: "auto"
|
|
||||||
// OpenAI Responses `include` wire field. Mirrors the official SDK's
|
|
||||||
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
|
|
||||||
// native-SDK callers share one shape and no translation is required.
|
|
||||||
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
|
||||||
readonly textVerbosity?: TextVerbosity
|
|
||||||
readonly serviceTier?: OpenAIServiceTier
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
||||||
readonly openai?: OpenAIOptionsInput
|
readonly openai?: OpenAIOptionsInput
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
|
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai")
|
export const id = ProviderID.make("openai")
|
||||||
|
|
||||||
@@ -22,6 +24,39 @@ export type Config = RouteDefaultsInput &
|
|||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationOptions {
|
||||||
|
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
|
||||||
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
|
readonly inputFidelity?: OpenAIImageString<"low" | "high">
|
||||||
|
readonly outputCompression?: number
|
||||||
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
|
readonly partialImages?: number
|
||||||
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||||
|
ToolDefinition.make({
|
||||||
|
name: "image_generation",
|
||||||
|
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
native: {
|
||||||
|
openai: {
|
||||||
|
type: "image_generation",
|
||||||
|
action: options.action,
|
||||||
|
background: options.background,
|
||||||
|
input_fidelity: options.inputFidelity,
|
||||||
|
output_compression: options.outputCompression,
|
||||||
|
output_format: options.outputFormat,
|
||||||
|
partial_images: options.partialImages,
|
||||||
|
quality: options.quality,
|
||||||
|
size: options.size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
@@ -51,10 +86,26 @@ export const configure = (input: Config = {}) => {
|
|||||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||||
const modelDefaults = defaults(input)
|
const modelDefaults = defaults(input)
|
||||||
const responses = (id: string | ModelID) =>
|
const responses = (id: string | ModelID) =>
|
||||||
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
responsesRoute
|
||||||
|
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id })
|
||||||
const responsesWebSocket = (id: string | ModelID) =>
|
const responsesWebSocket = (id: string | ModelID) =>
|
||||||
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
responsesWebSocketRoute
|
||||||
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||||
|
.model<OpenAIProviderOptionsInput>({ id })
|
||||||
|
const chat = (id: string | ModelID) =>
|
||||||
|
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
OpenAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: mergeHttpOptions(
|
||||||
|
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -62,6 +113,7 @@ export const configure = (input: Config = {}) => {
|
|||||||
responses,
|
responses,
|
||||||
responsesWebSocket,
|
responsesWebSocket,
|
||||||
chat,
|
chat,
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,15 +137,18 @@ const config = (settings: Settings): Config => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
const configured = configure(config(settings))
|
const configured = configure(config(settings))
|
||||||
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
||||||
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
||||||
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
configure(config(settings)).chat(modelID)
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const responsesWebSocket = provider.responsesWebSocket
|
export const responsesWebSocket = provider.responsesWebSocket
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
|
export const image = provider.image
|
||||||
|
|||||||
@@ -4,32 +4,90 @@ import { Endpoint } from "../route/endpoint"
|
|||||||
import { Framing } from "../route/framing"
|
import { Framing } from "../route/framing"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||||
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
|
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
|
||||||
import { isRecord } from "../protocols/shared"
|
import { isRecord } from "../protocols/shared"
|
||||||
|
|
||||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||||
export const id = ProviderID.make(profile.provider)
|
export const id = ProviderID.make(profile.provider)
|
||||||
const ADAPTER = "openrouter"
|
const ADAPTER = "openrouter"
|
||||||
|
|
||||||
|
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export interface OpenRouterProviderRouting {
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
readonly order?: ReadonlyArray<string>
|
||||||
|
readonly allow_fallbacks?: boolean
|
||||||
|
readonly require_parameters?: boolean
|
||||||
|
readonly data_collection?: OpenRouterString<"allow" | "deny">
|
||||||
|
readonly only?: ReadonlyArray<string>
|
||||||
|
readonly ignore?: ReadonlyArray<string>
|
||||||
|
readonly quantizations?: ReadonlyArray<string>
|
||||||
|
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
|
||||||
|
readonly max_price?: Readonly<{
|
||||||
|
prompt?: number | string
|
||||||
|
completion?: number | string
|
||||||
|
image?: number | string
|
||||||
|
audio?: number | string
|
||||||
|
request?: number | string
|
||||||
|
}>
|
||||||
|
readonly zdr?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenRouterPlugin =
|
||||||
|
| Readonly<{
|
||||||
|
id: "web"
|
||||||
|
max_results?: number
|
||||||
|
search_prompt?: string
|
||||||
|
engine?: OpenRouterString<"native" | "exa">
|
||||||
|
}>
|
||||||
|
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
|
||||||
|
| Readonly<{ id: "moderation" }>
|
||||||
|
| Readonly<{ id: "response-healing" }>
|
||||||
|
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
|
||||||
|
| Readonly<{ id: string & {}; [key: string]: unknown }>
|
||||||
|
|
||||||
export interface OpenRouterOptions {
|
export interface OpenRouterOptions {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
readonly usage?: boolean | Record<string, unknown>
|
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||||
readonly reasoning?: Record<string, unknown>
|
readonly models?: ReadonlyArray<string>
|
||||||
|
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||||
readonly promptCacheKey?: string
|
readonly promptCacheKey?: string
|
||||||
|
readonly provider?: OpenRouterProviderRouting
|
||||||
|
readonly reasoning?: Readonly<{
|
||||||
|
enabled?: boolean
|
||||||
|
exclude?: boolean
|
||||||
|
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
|
||||||
|
max_tokens?: number
|
||||||
|
}>
|
||||||
|
readonly usage?: boolean | Readonly<{ include: boolean }>
|
||||||
|
readonly user?: string
|
||||||
|
readonly web_search_options?: Readonly<{
|
||||||
|
max_results?: number
|
||||||
|
search_prompt?: string
|
||||||
|
engine?: OpenRouterString<"native" | "exa">
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenRouterProviderOptionsInput = ProviderOptions & {
|
export type OpenRouterProviderOptionsInput = ProviderOptions & {
|
||||||
readonly openrouter?: OpenRouterOptions
|
readonly openrouter?: OpenRouterOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
||||||
Schema.Record(Schema.String, Schema.Any),
|
Schema.Record(Schema.String, Schema.Any),
|
||||||
])
|
])
|
||||||
@@ -40,29 +98,70 @@ export const protocol = Protocol.make({
|
|||||||
body: {
|
body: {
|
||||||
schema: OpenRouterBody,
|
schema: OpenRouterBody,
|
||||||
from: (request) =>
|
from: (request) =>
|
||||||
OpenAIChat.protocol.body.from(request).pipe(
|
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
|
||||||
Effect.map(
|
Effect.map((body) => {
|
||||||
(body) =>
|
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||||
({
|
let assistantIndex = 0
|
||||||
...body,
|
const messages = body.messages.map((message) => {
|
||||||
...bodyOptions(request.providerOptions?.openrouter),
|
if (message.role !== "assistant") return message
|
||||||
}) as OpenRouterBody,
|
const source = sourceAssistants[assistantIndex++]
|
||||||
),
|
const reasoning = source?.content
|
||||||
|
.filter((part) => part.type === "reasoning")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("")
|
||||||
|
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
reasoning_content: undefined,
|
||||||
|
reasoning_text: undefined,
|
||||||
|
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
|
||||||
|
reasoning_details: reasoningDetails,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
messages,
|
||||||
|
...bodyOptions(request.providerOptions?.openrouter),
|
||||||
|
} as OpenRouterBody
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
stream: OpenAIChat.protocol.stream,
|
stream: OpenAIChat.protocol.stream,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const cacheControl = () => {
|
||||||
|
const breakpoints = newBreakpoints(4)
|
||||||
|
return (cache: CacheHint | undefined) => {
|
||||||
|
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||||
|
breakpoints.remaining -= 1
|
||||||
|
return {
|
||||||
|
type: "ephemeral" as const,
|
||||||
|
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bodyOptions = (input: unknown) => {
|
const bodyOptions = (input: unknown) => {
|
||||||
const openrouter = isRecord(input) ? input : {}
|
const openrouter = isRecord(input) ? input : {}
|
||||||
|
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
|
||||||
|
openrouter
|
||||||
return {
|
return {
|
||||||
...(openrouter.usage === true
|
...options,
|
||||||
|
...(usage === undefined || usage === true
|
||||||
? { usage: { include: true } }
|
? { usage: { include: true } }
|
||||||
: isRecord(openrouter.usage)
|
: usage === false
|
||||||
? { usage: openrouter.usage }
|
? { usage: { include: false } }
|
||||||
: {}),
|
: isRecord(usage)
|
||||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
? { usage }
|
||||||
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
|
: {}),
|
||||||
|
...(Array.isArray(models) ? { models } : {}),
|
||||||
|
...(isRecord(provider) ? { provider } : {}),
|
||||||
|
...(Array.isArray(plugins) ? { plugins } : {}),
|
||||||
|
...(isRecord(web_search_options) ? { web_search_options } : {}),
|
||||||
|
...(isRecord(debug) ? { debug } : {}),
|
||||||
|
...(typeof user === "string" ? { user } : {}),
|
||||||
|
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||||
|
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +175,7 @@ export const route = Route.make({
|
|||||||
|
|
||||||
export const routes = [route]
|
export const routes = [route]
|
||||||
|
|
||||||
const configuredRoute = (input: ModelOptions) => {
|
const configuredRoute = (input: LanguageModelOptions) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||||
return route.with({
|
return route.with({
|
||||||
...rest,
|
...rest,
|
||||||
@@ -85,14 +184,25 @@ const configuredRoute = (input: ModelOptions) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configure = (input: ModelOptions = {}) => {
|
export const configure = (input: LanguageModelOptions = {}) => {
|
||||||
const route = configuredRoute(input)
|
const route = configuredRoute(input)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model = provider.model
|
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) =>
|
||||||
|
configure({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers,
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
|||||||
@@ -1,56 +1,109 @@
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||||
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
|
import { XAIImages } from "../protocols/xai-images"
|
||||||
|
import type { OpenAIOptionsInput } from "./openai-options"
|
||||||
|
import type { ProviderPackage } from "../provider-package"
|
||||||
|
|
||||||
export const id = ProviderID.make("xai")
|
export const id = ProviderID.make("xai")
|
||||||
|
|
||||||
export type ModelOptions = RouteDefaultsInput &
|
export type XAIProviderOptionsInput = ProviderOptions & {
|
||||||
|
readonly xai?: OpenAIOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: XAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly providerOptions?: XAIProviderOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||||
|
|
||||||
|
const responsesRoute = Route.make({
|
||||||
|
id: "openai-responses",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "xai",
|
||||||
|
protocol: OpenAIResponses.protocol,
|
||||||
|
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
|
transport: OpenAIResponses.httpTransport,
|
||||||
|
defaults: { providerOptions: { xai: { store: false } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatRoute = Route.make({
|
||||||
|
id: "openai-compatible-chat",
|
||||||
|
provider: id,
|
||||||
|
providerMetadataKey: "xai",
|
||||||
|
protocol: OpenAIChat.protocol,
|
||||||
|
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
|
transport: OpenAICompatibleChat.route.transport,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const routes = [responsesRoute, chatRoute]
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||||
|
|
||||||
const configuredResponsesRoute = (input: ModelOptions) => {
|
const configuredResponsesRoute = (input: LanguageModelOptions) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||||
return OpenAIResponses.route.with({
|
return responsesRoute.with({
|
||||||
...rest,
|
...rest,
|
||||||
provider: id,
|
|
||||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredChatRoute = (input: ModelOptions) => {
|
const configuredChatRoute = (input: LanguageModelOptions) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||||
return OpenAICompatibleChat.route.with({
|
return chatRoute.with({
|
||||||
...rest,
|
...rest,
|
||||||
provider: id,
|
|
||||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configure = (input: ModelOptions = {}) => {
|
export const configure = (input: LanguageModelOptions = {}) => {
|
||||||
const responsesRoute = configuredResponsesRoute(input)
|
const responsesRoute = configuredResponsesRoute(input)
|
||||||
const chatRoute = configuredChatRoute(input)
|
const chatRoute = configuredChatRoute(input)
|
||||||
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||||
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
XAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model = provider.model
|
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
|
configure({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseURL,
|
||||||
|
headers: settings.headers,
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
limits: settings.limits,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
|
export const image = provider.image
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ZAIImages } from "../protocols/zai-images"
|
||||||
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("zai")
|
||||||
|
|
||||||
|
export type Config = ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ZAIImageOptions } from "../protocols/zai-images"
|
||||||
|
|
||||||
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
ZAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
image,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = configure()
|
||||||
|
export const image = provider.image
|
||||||
@@ -22,13 +22,17 @@ export type ProviderAuthOption<Mode extends ApiKeyMode> =
|
|||||||
| AuthOverride
|
| AuthOverride
|
||||||
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
|
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
|
||||||
|
|
||||||
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode>
|
export type LanguageModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> &
|
||||||
|
ProviderAuthOption<Mode>
|
||||||
|
|
||||||
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
|
export type LanguageModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
|
||||||
? readonly [options?: ModelOptions<Base, Mode>]
|
? readonly [options?: LanguageModelOptions<Base, Mode>]
|
||||||
: readonly [options: ModelOptions<Base, Mode>]
|
: readonly [options: LanguageModelOptions<Base, Mode>]
|
||||||
|
|
||||||
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model
|
export type LanguageModelFactory<Base, Mode extends ApiKeyMode, LanguageModel> = (
|
||||||
|
id: string,
|
||||||
|
...args: LanguageModelArgs<Base, Mode>
|
||||||
|
) => LanguageModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require at least one of the keys in `T`. Use for option shapes where any
|
* Require at least one of the keys in `T`. Use for option shapes where any
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Config, Effect, Redacted } from "effect"
|
import { Config, Effect, Redacted } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema"
|
||||||
|
|
||||||
export class MissingCredentialError extends Error {
|
export class MissingCredentialError extends Error {
|
||||||
readonly _tag = "MissingCredentialError"
|
readonly _tag = "MissingCredentialError"
|
||||||
@@ -11,11 +11,11 @@ export class MissingCredentialError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CredentialError = MissingCredentialError | Config.ConfigError
|
export type CredentialError = MissingCredentialError | Config.ConfigError
|
||||||
export type AuthError = CredentialError | LLMError
|
export type AuthError = CredentialError | AIError
|
||||||
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||||
|
|
||||||
export interface AuthInput {
|
export interface AuthInput {
|
||||||
readonly request: LLMRequest
|
readonly request: { readonly http?: HttpOptions }
|
||||||
readonly method: "POST" | "GET"
|
readonly method: "POST" | "GET"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly body: string
|
readonly body: string
|
||||||
@@ -100,7 +100,7 @@ export const headers = (input: Headers.Input) =>
|
|||||||
|
|
||||||
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
|
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
|
||||||
|
|
||||||
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply)
|
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AIError>) => auth(apply)
|
||||||
|
|
||||||
export const passthrough = none
|
export const passthrough = none
|
||||||
|
|
||||||
@@ -134,9 +134,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
|||||||
return render(source)
|
return render(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toLLMError = (error: AuthError): LLMError => {
|
const toAIError = (error: AuthError): AIError => {
|
||||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||||
return new LLMError({
|
return new AIError({
|
||||||
module: "Auth",
|
module: "Auth",
|
||||||
method: "apply",
|
method: "apply",
|
||||||
reason:
|
reason:
|
||||||
@@ -150,7 +150,7 @@ const toLLMError = (error: AuthError): LLMError => {
|
|||||||
|
|
||||||
export const toEffect =
|
export const toEffect =
|
||||||
(input: Definition) =>
|
(input: Definition) =>
|
||||||
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> =>
|
(authInput: AuthInput): Effect.Effect<Headers.Headers, AIError> =>
|
||||||
input.apply(authInput).pipe(Effect.mapError(toLLMError))
|
input.apply(authInput).pipe(Effect.mapError(toAIError))
|
||||||
|
|
||||||
export * as Auth from "./auth"
|
export * as Auth from "./auth"
|
||||||
|
|||||||
@@ -5,22 +5,21 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
|||||||
import { RequestExecutor } from "./executor"
|
import { RequestExecutor } from "./executor"
|
||||||
import { Framing } from "./framing"
|
import { Framing } from "./framing"
|
||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
import { WebSocketExecutor } from "./transport"
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol } from "./protocol"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
import type { ProtocolID, ProviderOptions } from "../schema"
|
||||||
import {
|
import {
|
||||||
|
AIError,
|
||||||
GenerationOptions,
|
GenerationOptions,
|
||||||
HttpOptions,
|
HttpOptions,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
Model,
|
LanguageModel,
|
||||||
ModelLimits,
|
LanguageModelLimits,
|
||||||
LLMError as LLMErrorClass,
|
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
PreparedRequest,
|
|
||||||
ProviderID,
|
ProviderID,
|
||||||
mergeGenerationOptions,
|
mergeGenerationOptions,
|
||||||
mergeHttpOptions,
|
mergeHttpOptions,
|
||||||
@@ -31,7 +30,7 @@ export interface RouteBody<Body> {
|
|||||||
/** Schema for the validated provider-native body sent as the JSON request. */
|
/** Schema for the validated provider-native body sent as the JSON request. */
|
||||||
readonly schema: Schema.Codec<Body, unknown>
|
readonly schema: Schema.Codec<Body, unknown>
|
||||||
/** Build the provider-native body from a common `LLMRequest`. */
|
/** Build the provider-native body from a common `LLMRequest`. */
|
||||||
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
|
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Route<Body, Prepared = unknown> {
|
export interface Route<Body, Prepared = unknown> {
|
||||||
@@ -46,13 +45,19 @@ export interface Route<Body, Prepared = unknown> {
|
|||||||
readonly defaults: RouteDefaults
|
readonly defaults: RouteDefaults
|
||||||
readonly body: RouteBody<Body>
|
readonly body: RouteBody<Body>
|
||||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||||
readonly model: (input: RouteMappedModelInput) => Model
|
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
input: RouteMappedLanguageModelInput,
|
||||||
|
) => LanguageModel<Options>
|
||||||
|
readonly prepareTransport: (
|
||||||
|
body: Body,
|
||||||
|
request: LLMRequest,
|
||||||
|
options?: StreamOptions,
|
||||||
|
) => Effect.Effect<Prepared, AIError>
|
||||||
readonly streamPrepared: (
|
readonly streamPrepared: (
|
||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
runtime: TransportRuntime,
|
runtime: TransportRuntime,
|
||||||
) => Stream.Stream<LLMEvent, LLMError>
|
) => Stream.Stream<LLMEvent, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route registries intentionally erase body generics after construction.
|
// Route registries intentionally erase body generics after construction.
|
||||||
@@ -63,13 +68,13 @@ export type AnyRoute = Route<any, any>
|
|||||||
|
|
||||||
export type HttpOptionsInput = HttpOptions.Input
|
export type HttpOptionsInput = HttpOptions.Input
|
||||||
|
|
||||||
export type RouteModelInput = Omit<Model.Input, "provider" | "route">
|
export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "route">
|
||||||
|
|
||||||
export type RouteRoutedModelInput = Omit<Model.Input, "route">
|
export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
|
||||||
|
|
||||||
export interface RouteDefaults {
|
export interface RouteDefaults {
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly limits?: ModelLimits
|
readonly limits?: LanguageModelLimits
|
||||||
readonly generation?: GenerationOptions
|
readonly generation?: GenerationOptions
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: ProviderOptions
|
||||||
readonly http?: HttpOptions
|
readonly http?: HttpOptions
|
||||||
@@ -77,7 +82,7 @@ export interface RouteDefaults {
|
|||||||
|
|
||||||
export interface RouteDefaultsInput {
|
export interface RouteDefaultsInput {
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly limits?: ModelLimits.Input
|
readonly limits?: LanguageModelLimits.Input
|
||||||
readonly generation?: GenerationOptions.Input
|
readonly generation?: GenerationOptions.Input
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: ProviderOptions
|
||||||
readonly http?: HttpOptions.Input
|
readonly http?: HttpOptions.Input
|
||||||
@@ -91,14 +96,17 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
|||||||
readonly endpoint?: EndpointPatch<Body>
|
readonly endpoint?: EndpointPatch<Body>
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||||
|
|
||||||
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
|
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||||
|
route: AnyRoute,
|
||||||
|
mapped: RouteMappedLanguageModelInput,
|
||||||
|
) => {
|
||||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||||
if (!endpointBaseURL(route.endpoint))
|
if (!endpointBaseURL(route.endpoint))
|
||||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||||
return Model.make({
|
return LanguageModel.make<Options>({
|
||||||
...mapped,
|
...mapped,
|
||||||
provider,
|
provider,
|
||||||
route,
|
route,
|
||||||
@@ -111,7 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
|
|||||||
...base,
|
...base,
|
||||||
...patch,
|
...patch,
|
||||||
headers,
|
headers,
|
||||||
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
|
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
|
||||||
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
||||||
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
||||||
http: mergeHttpOptions(
|
http: mergeHttpOptions(
|
||||||
@@ -142,27 +150,21 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/**
|
|
||||||
* Compile a request through protocol body construction, validation, and HTTP
|
|
||||||
* preparation without sending it. Returns the prepared request including the
|
|
||||||
* provider-native body.
|
|
||||||
*
|
|
||||||
* Pass a `Body` type argument to statically expose the route's body
|
|
||||||
* shape (e.g. `prepare<OpenAIChatBody>(...)`) — the runtime body is
|
|
||||||
* identical, so this is a type-level assertion the caller makes about which
|
|
||||||
* route the request will resolve to.
|
|
||||||
*/
|
|
||||||
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
|
||||||
readonly stream: StreamMethod
|
readonly stream: StreamMethod
|
||||||
readonly generate: GenerateMethod
|
readonly generate: GenerateMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
readonly transform?: HttpRequestTransform
|
||||||
|
readonly http?: HttpMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateMethod {
|
export interface GenerateMethod {
|
||||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||||
@@ -226,11 +228,11 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
|||||||
|
|
||||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||||
if (failed instanceof LLMErrorClass) return failed
|
if (failed instanceof AIError) return failed
|
||||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) =>
|
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||||
Stream.suspend(() => {
|
Stream.suspend(() => {
|
||||||
let terminal = false
|
let terminal = false
|
||||||
return events.pipe(
|
return events.pipe(
|
||||||
@@ -296,8 +298,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
defaults: mergeRouteDefaults(route.defaults, defaults),
|
defaults: mergeRouteDefaults(route.defaults, defaults),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
model: (input) => makeRouteModel(route, input),
|
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||||
prepareTransport: (body, request) =>
|
makeRouteLanguageModel<Options>(route, input),
|
||||||
|
prepareTransport: (body, request, options) =>
|
||||||
routeInput.transport.prepare({
|
routeInput.transport.prepare({
|
||||||
body,
|
body,
|
||||||
request,
|
request,
|
||||||
@@ -305,6 +308,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
auth: routeInput.auth ?? Auth.none,
|
auth: routeInput.auth ?? Auth.none,
|
||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
|
transform: options?.transform,
|
||||||
|
middleware: options?.http,
|
||||||
}),
|
}),
|
||||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
@@ -370,17 +375,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||||
// validated provider body plus transport-private prepared data, but does not
|
|
||||||
// execute transport.
|
|
||||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
|
||||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||||
const route = resolved.model.route
|
const route = resolved.model.route
|
||||||
|
|
||||||
const body = yield* route.body
|
const body = yield* route.body
|
||||||
.from(resolved)
|
.from(resolved)
|
||||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||||
const prepared = yield* route.prepareTransport(body, resolved)
|
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request: resolved,
|
request: resolved,
|
||||||
@@ -390,30 +392,30 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
|
/** @internal Test-only projection of the execution compiler; not exported from package barrels. */
|
||||||
|
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
|
||||||
const compiled = yield* compile(request)
|
const compiled = yield* compile(request)
|
||||||
|
return {
|
||||||
return new PreparedRequest({
|
|
||||||
id: compiled.request.id ?? "request",
|
id: compiled.request.id ?? "request",
|
||||||
route: compiled.route.id,
|
route: compiled.route.id,
|
||||||
protocol: compiled.route.protocol,
|
protocol: compiled.route.protocol,
|
||||||
model: compiled.request.model,
|
model: compiled.request.model,
|
||||||
body: compiled.body,
|
body: compiled.body,
|
||||||
metadata: { transport: compiled.route.transport.id },
|
metadata: { transport: compiled.route.transport.id },
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request)
|
const compiled = yield* compile(request, options)
|
||||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const generateWith = (stream: Interface["stream"]) =>
|
const generateWith = (stream: Interface["stream"]) =>
|
||||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||||
const response = LLMResponse.complete(state)
|
const response = LLMResponse.complete(state)
|
||||||
if (response) return response
|
if (response) return response
|
||||||
return yield* ProviderShared.eventError(
|
return yield* ProviderShared.eventError(
|
||||||
@@ -422,27 +424,24 @@ const generateWith = (stream: Interface["stream"]) =>
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||||
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
|
||||||
|
|
||||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
|
||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (yield* Service).stream(request)
|
return (yield* Service).stream(request, options)
|
||||||
}),
|
}),
|
||||||
) as Stream.Stream<LLMEvent, LLMError>
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
return yield* (yield* Service).generate(request)
|
return yield* (yield* Service).generate(request, options)
|
||||||
}) as Effect.Effect<LLMResponse, LLMError>
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const streamRequest = (request: LLMRequest) =>
|
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return (yield* Service).stream(request)
|
return (yield* Service).stream(request, options)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -453,7 +452,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
|||||||
http: yield* RequestExecutor.Service,
|
http: yield* RequestExecutor.Service,
|
||||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||||
})
|
})
|
||||||
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
|
return Service.of({ stream, generate: generateWith(stream) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -462,7 +461,6 @@ export const Route = { make } as const
|
|||||||
export const LLMClient = {
|
export const LLMClient = {
|
||||||
Service,
|
Service,
|
||||||
layer,
|
layer,
|
||||||
prepare,
|
|
||||||
stream,
|
stream,
|
||||||
generate,
|
generate,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
HttpRateLimitDetails,
|
HttpRateLimitDetails,
|
||||||
HttpRequestDetails,
|
HttpRequestDetails,
|
||||||
HttpResponseDetails,
|
HttpResponseDetails,
|
||||||
LLMError,
|
AIError,
|
||||||
TransportReason,
|
TransportReason,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { classifyProviderFailure } from "../provider-error"
|
import { classifyProviderFailure } from "../provider-error"
|
||||||
@@ -20,10 +20,19 @@ import { classifyProviderFailure } from "../provider-error"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
middleware?: HttpMiddleware,
|
||||||
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
export type HttpHandler = (
|
||||||
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||||
|
export type HttpMiddleware = (
|
||||||
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
handler: HttpHandler,
|
||||||
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const BODY_LIMIT = 16_384
|
||||||
const REDACTED = "<redacted>"
|
const REDACTED = "<redacted>"
|
||||||
@@ -220,7 +229,7 @@ const statusError =
|
|||||||
const retryAfter = retryAfterMs(headers)
|
const retryAfter = retryAfterMs(headers)
|
||||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||||
const details = responseBody(body, request)
|
const details = responseBody(body, request)
|
||||||
return yield* new LLMError({
|
return yield* new AIError({
|
||||||
module: "RequestExecutor",
|
module: "RequestExecutor",
|
||||||
method: "execute",
|
method: "execute",
|
||||||
reason: classifyProviderFailure({
|
reason: classifyProviderFailure({
|
||||||
@@ -246,7 +255,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
|
|||||||
readonly kind?: string | undefined
|
readonly kind?: string | undefined
|
||||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||||
}) =>
|
}) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: "RequestExecutor",
|
module: "RequestExecutor",
|
||||||
method: "execute",
|
method: "execute",
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({
|
||||||
@@ -282,12 +291,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
return yield* http
|
if (!middleware)
|
||||||
.execute(request)
|
return yield* http
|
||||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
.execute(request)
|
||||||
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
|
|
||||||
|
const response = yield* middleware(request, (input) =>
|
||||||
|
http
|
||||||
|
.execute(input)
|
||||||
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
|
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||||
|
return yield* statusError(response.request, redactedNames)(response)
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Stream } from "effect"
|
import type { Stream } from "effect"
|
||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
import type { LLMError } from "../schema"
|
import type { AIError } from "../schema"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a streaming HTTP response body into provider-protocol frames.
|
* Decode a streaming HTTP response body into provider-protocol frames.
|
||||||
@@ -18,7 +18,7 @@ import type { LLMError } from "../schema"
|
|||||||
*/
|
*/
|
||||||
export interface Definition<Frame> {
|
export interface Definition<Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError>
|
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
export { Route, LLMClient } from "./client"
|
export { Route, LLMClient } from "./client"
|
||||||
export type {
|
export type {
|
||||||
Route as RouteShape,
|
Route as RouteShape,
|
||||||
RouteModelInput,
|
RouteLanguageModelInput,
|
||||||
RouteRoutedModelInput,
|
RouteRoutedLanguageModelInput,
|
||||||
RouteDefaults,
|
RouteDefaults,
|
||||||
RouteDefaultsInput,
|
RouteDefaultsInput,
|
||||||
AnyRoute,
|
AnyRoute,
|
||||||
Interface as LLMClientShape,
|
Interface as LLMClientShape,
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
|
StreamOptions,
|
||||||
} from "./client"
|
} from "./client"
|
||||||
export * from "./executor"
|
export * from "./executor"
|
||||||
export { Auth } from "./auth"
|
export { Auth } from "./auth"
|
||||||
@@ -22,4 +23,11 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
|||||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||||
export type { Definition as FramingDef } from "./framing"
|
export type { Definition as FramingDef } from "./framing"
|
||||||
export type { Protocol as ProtocolDef } from "./protocol"
|
export type { Protocol as ProtocolDef } from "./protocol"
|
||||||
export type { Transport as TransportDef, TransportRuntime } from "./transport"
|
export type {
|
||||||
|
HttpHandler,
|
||||||
|
HttpMiddleware,
|
||||||
|
HttpRequest,
|
||||||
|
HttpRequestTransform,
|
||||||
|
Transport as TransportDef,
|
||||||
|
TransportRuntime,
|
||||||
|
} from "./transport"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Schema, type Effect } from "effect"
|
import { Schema, type Effect } from "effect"
|
||||||
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The semantic API contract of one model server family.
|
* The semantic API contract of one model server family.
|
||||||
@@ -12,7 +12,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
|||||||
* Examples:
|
* Examples:
|
||||||
*
|
*
|
||||||
* - `OpenAIChat.protocol` — chat completions style
|
* - `OpenAIChat.protocol` — chat completions style
|
||||||
* - `OpenAIResponses.protocol` — responses API
|
* - `OpenResponses.protocol` — provider-neutral Responses API baseline
|
||||||
|
* - `OpenAIResponses.protocol` — OpenAI extensions to that baseline
|
||||||
* - `AnthropicMessages.protocol` — messages API with content blocks
|
* - `AnthropicMessages.protocol` — messages API with content blocks
|
||||||
* - `Gemini.protocol` — generateContent
|
* - `Gemini.protocol` — generateContent
|
||||||
* - `BedrockConverse.protocol` — Converse with binary event-stream framing
|
* - `BedrockConverse.protocol` — Converse with binary event-stream framing
|
||||||
@@ -46,7 +47,7 @@ export interface ProtocolBody<Body> {
|
|||||||
/** Schema for the validated provider-native body sent as the JSON request. */
|
/** Schema for the validated provider-native body sent as the JSON request. */
|
||||||
readonly schema: Schema.Codec<Body, unknown>
|
readonly schema: Schema.Codec<Body, unknown>
|
||||||
/** Build the provider-native body from a common `LLMRequest`. */
|
/** Build the provider-native body from a common `LLMRequest`. */
|
||||||
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError>
|
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProtocolStream<Frame, Event, State> {
|
export interface ProtocolStream<Frame, Event, State> {
|
||||||
@@ -55,7 +56,7 @@ export interface ProtocolStream<Frame, Event, State> {
|
|||||||
/** Initial parser state. Called once per response with the resolved request. */
|
/** Initial parser state. Called once per response with the resolved request. */
|
||||||
readonly initial: (request: LLMRequest) => State
|
readonly initial: (request: LLMRequest) => State
|
||||||
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
||||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
|
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
|
||||||
/** Optional request-completion signal for transports that do not end naturally. */
|
/** Optional request-completion signal for transports that do not end naturally. */
|
||||||
readonly terminal?: (event: Event) => boolean
|
readonly terminal?: (event: Event) => boolean
|
||||||
/** Optional flush emitted when the framed stream ends. */
|
/** Optional flush emitted when the framed stream ends. */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
|||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { render as renderEndpoint } from "../endpoint"
|
import { render as renderEndpoint } from "../endpoint"
|
||||||
import { Framing } from "../framing"
|
import { Framing } from "../framing"
|
||||||
import type { Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
|||||||
export interface HttpPrepared<Frame> {
|
export interface HttpPrepared<Frame> {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly framing: Framing.Definition<Frame>
|
readonly framing: Framing.Definition<Frame>
|
||||||
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
@@ -28,57 +29,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
|||||||
return next.toString()
|
return next.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
|
||||||
"anthropic_version",
|
|
||||||
"content",
|
|
||||||
"contents",
|
|
||||||
"frequencyPenalty",
|
|
||||||
"frequency_penalty",
|
|
||||||
"generationConfig",
|
|
||||||
"inferenceConfig",
|
|
||||||
"input",
|
|
||||||
"maxTokens",
|
|
||||||
"max_tokens",
|
|
||||||
"messages",
|
|
||||||
"model",
|
|
||||||
"presencePenalty",
|
|
||||||
"presence_penalty",
|
|
||||||
"responseFormat",
|
|
||||||
"response_format",
|
|
||||||
"seed",
|
|
||||||
"stop",
|
|
||||||
"stopSequences",
|
|
||||||
"stop_sequences",
|
|
||||||
"stream",
|
|
||||||
"streamOptions",
|
|
||||||
"stream_options",
|
|
||||||
"system",
|
|
||||||
"systemInstruction",
|
|
||||||
"system_instruction",
|
|
||||||
"temperature",
|
|
||||||
"thinking",
|
|
||||||
"toolChoice",
|
|
||||||
"toolConfig",
|
|
||||||
"tool_choice",
|
|
||||||
"tool_config",
|
|
||||||
"tools",
|
|
||||||
"topK",
|
|
||||||
"topP",
|
|
||||||
"top_k",
|
|
||||||
"top_p",
|
|
||||||
])
|
|
||||||
|
|
||||||
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
|
||||||
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
|
||||||
|
|
||||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||||
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
|
||||||
if (forbiddenKeys.length > 0)
|
|
||||||
return yield* ProviderShared.invalidRequest(
|
|
||||||
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
|
||||||
)
|
|
||||||
if (ProviderShared.isRecord(body)) {
|
if (ProviderShared.isRecord(body)) {
|
||||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||||
@@ -120,18 +73,25 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
id: "http-json",
|
id: "http-json",
|
||||||
with: (patch) => httpJson({ ...input, ...patch }),
|
with: (patch) => httpJson({ ...input, ...patch }),
|
||||||
prepare: (prepareInput) =>
|
prepare: (prepareInput) =>
|
||||||
jsonRequestParts({
|
Effect.gen(function* () {
|
||||||
...prepareInput,
|
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||||
}).pipe(
|
const transformed = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||||
Effect.map((parts) => ({
|
yield* prepareInput.transform?.(transformed) ?? Effect.void
|
||||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
const request = ProviderShared.jsonPost({
|
||||||
|
url: transformed.url,
|
||||||
|
body: transformed.body ?? "",
|
||||||
|
headers: Headers.fromInput(transformed.headers),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
request,
|
||||||
framing: input.framing,
|
framing: input.framing,
|
||||||
})),
|
middleware: prepareInput.middleware,
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
frames: (prepared, request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
runtime.http
|
runtime.http
|
||||||
.execute(prepared.request)
|
.execute(prepared.request, prepared.middleware)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((response) =>
|
Effect.map((response) =>
|
||||||
prepared.framing.frame(
|
prepared.framing.frame(
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Stream } from "effect"
|
||||||
import { Endpoint } from "../endpoint"
|
import { Endpoint } from "../endpoint"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||||
import type { LLMError, LLMRequest } from "../../schema"
|
import type { AIError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
export interface TransportRuntime {
|
export interface TransportRuntime {
|
||||||
readonly http: RequestExecutorInterface
|
readonly http: RequestExecutorInterface
|
||||||
readonly webSocket?: WebSocketExecutorInterface
|
readonly webSocket?: WebSocketExecutorInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HttpRequest {
|
||||||
|
url: string
|
||||||
|
readonly method: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
||||||
|
|
||||||
export interface Transport<Body, Prepared, Frame> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||||
readonly frames: (
|
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
||||||
prepared: Prepared,
|
|
||||||
request: LLMRequest,
|
|
||||||
runtime: TransportRuntime,
|
|
||||||
) => Stream.Stream<Frame, LLMError>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransportPrepareInput<Body> {
|
export interface TransportPrepareInput<Body> {
|
||||||
@@ -27,7 +32,10 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly auth: Auth.Definition
|
readonly auth: Auth.Definition
|
||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
|
readonly transform?: HttpRequestTransform
|
||||||
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http"
|
export * as HttpTransport from "./http"
|
||||||
|
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { LLMError, TransportReason } from "../../schema"
|
import { AIError, TransportReason } from "../../schema"
|
||||||
import * as HttpTransport from "./http"
|
import * as HttpTransport from "./http"
|
||||||
import type { Transport } from "./index"
|
import type { Transport } from "./index"
|
||||||
|
|
||||||
@@ -10,13 +10,13 @@ export interface WebSocketRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WebSocketConnection {
|
export interface WebSocketConnection {
|
||||||
readonly sendText: (message: string) => Effect.Effect<void, LLMError>
|
readonly sendText: (message: string) => Effect.Effect<void, AIError>
|
||||||
readonly messages: Stream.Stream<string | Uint8Array, LLMError>
|
readonly messages: Stream.Stream<string | Uint8Array, AIError>
|
||||||
readonly close: Effect.Effect<void, never>
|
readonly close: Effect.Effect<void, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, LLMError>
|
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketConstructorWithHeaders = new (
|
type WebSocketConstructorWithHeaders = new (
|
||||||
@@ -24,14 +24,14 @@ type WebSocketConstructorWithHeaders = new (
|
|||||||
options?: { readonly headers?: Headers.Headers },
|
options?: { readonly headers?: Headers.Headers },
|
||||||
) => globalThis.WebSocket
|
) => globalThis.WebSocket
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
||||||
|
|
||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly url?: string; readonly kind?: string } = {},
|
input: { readonly url?: string; readonly kind?: string } = {},
|
||||||
) =>
|
) =>
|
||||||
new LLMError({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketExecutor",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||||
@@ -59,7 +59,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Effect.callback<void, LLMError>((resume, signal) => {
|
return Effect.callback<void, AIError>((resume, signal) => {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
ws.removeEventListener("open", onOpen)
|
ws.removeEventListener("open", onOpen)
|
||||||
ws.removeEventListener("error", onError)
|
ws.removeEventListener("error", onError)
|
||||||
@@ -138,10 +138,10 @@ export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ o
|
|||||||
export const fromWebSocket = (
|
export const fromWebSocket = (
|
||||||
ws: globalThis.WebSocket,
|
ws: globalThis.WebSocket,
|
||||||
input: WebSocketRequest,
|
input: WebSocketRequest,
|
||||||
): Effect.Effect<WebSocketConnection, LLMError> =>
|
): Effect.Effect<WebSocketConnection, AIError> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* waitOpen(ws, input)
|
yield* waitOpen(ws, input)
|
||||||
const messages = yield* Queue.bounded<string | Uint8Array, LLMError | Cause.Done<void>>(128)
|
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
||||||
|
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
|
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
|
||||||
@@ -213,7 +213,7 @@ export interface JsonPrepared {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonInput<Body, Message> {
|
export interface JsonInput<Body, Message> {
|
||||||
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError>
|
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, AIError>
|
||||||
readonly encodeMessage: (message: Message) => string
|
readonly encodeMessage: (message: Message) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||||
|
|
||||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||||
|
|
||||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
headers: Schema.Record(Schema.String, Schema.String),
|
headers: Schema.Record(Schema.String, Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({
|
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
|
||||||
status: Schema.Number,
|
status: Schema.Number,
|
||||||
headers: Schema.Record(Schema.String, Schema.String),
|
headers: Schema.Record(Schema.String, Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("LLM.HttpRateLimitDetails")({
|
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
|
||||||
retryAfterMs: Schema.optional(Schema.Number),
|
retryAfterMs: Schema.optional(Schema.Number),
|
||||||
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||||
request: HttpRequestDetails,
|
request: HttpRequestDetails,
|
||||||
response: Schema.optional(HttpResponseDetails),
|
response: Schema.optional(HttpResponseDetails),
|
||||||
body: Schema.optional(Schema.String),
|
body: Schema.optional(Schema.String),
|
||||||
@@ -31,7 +32,7 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
|||||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
|
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
|
||||||
_tag: Schema.tag("InvalidRequest"),
|
_tag: Schema.tag("InvalidRequest"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
parameter: Schema.optional(Schema.String),
|
parameter: Schema.optional(Schema.String),
|
||||||
@@ -40,18 +41,18 @@ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LL
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
|
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
|
||||||
_tag: Schema.tag("NoRoute"),
|
_tag: Schema.tag("NoRoute"),
|
||||||
route: RouteID,
|
route: RouteID,
|
||||||
provider: ProviderID,
|
provider: ProviderID,
|
||||||
model: ModelID,
|
model: ModelID,
|
||||||
}) {
|
}) {
|
||||||
get message() {
|
get message() {
|
||||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
|
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
|
||||||
_tag: Schema.tag("Authentication"),
|
_tag: Schema.tag("Authentication"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||||
@@ -59,7 +60,7 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LL
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
|
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
|
||||||
_tag: Schema.tag("RateLimit"),
|
_tag: Schema.tag("RateLimit"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
retryAfterMs: Schema.optional(Schema.Number),
|
retryAfterMs: Schema.optional(Schema.Number),
|
||||||
@@ -68,21 +69,21 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.Ra
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
|
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
|
||||||
_tag: Schema.tag("QuotaExceeded"),
|
_tag: Schema.tag("QuotaExceeded"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
|
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
|
||||||
_tag: Schema.tag("ContentPolicy"),
|
_tag: Schema.tag("ContentPolicy"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
|
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
|
||||||
_tag: Schema.tag("ProviderInternal"),
|
_tag: Schema.tag("ProviderInternal"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
status: Schema.optional(Schema.Number),
|
status: Schema.optional(Schema.Number),
|
||||||
@@ -91,7 +92,7 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
|
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
||||||
_tag: Schema.tag("Transport"),
|
_tag: Schema.tag("Transport"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
kind: Schema.optional(Schema.String),
|
kind: Schema.optional(Schema.String),
|
||||||
@@ -100,7 +101,7 @@ export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Tr
|
|||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||||
"LLM.Error.InvalidProviderOutput",
|
"AI.Error.InvalidProviderOutput",
|
||||||
)({
|
)({
|
||||||
_tag: Schema.tag("InvalidProviderOutput"),
|
_tag: Schema.tag("InvalidProviderOutput"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
@@ -109,7 +110,7 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
|||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
|
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
|
||||||
_tag: Schema.tag("UnknownProvider"),
|
_tag: Schema.tag("UnknownProvider"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
status: Schema.optional(Schema.Number),
|
status: Schema.optional(Schema.Number),
|
||||||
@@ -117,7 +118,7 @@ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const LLMErrorReason = Schema.Union([
|
export const AIErrorReason = Schema.Union([
|
||||||
InvalidRequestReason,
|
InvalidRequestReason,
|
||||||
NoRouteReason,
|
NoRouteReason,
|
||||||
AuthenticationReason,
|
AuthenticationReason,
|
||||||
@@ -129,12 +130,12 @@ export const LLMErrorReason = Schema.Union([
|
|||||||
InvalidProviderOutputReason,
|
InvalidProviderOutputReason,
|
||||||
UnknownProviderReason,
|
UnknownProviderReason,
|
||||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||||
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
|
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||||
|
|
||||||
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
|
||||||
module: Schema.String,
|
module: Schema.String,
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
reason: LLMErrorReason,
|
reason: AIErrorReason,
|
||||||
}) {
|
}) {
|
||||||
override readonly cause = this.reason
|
override readonly cause = this.reason
|
||||||
|
|
||||||
@@ -152,8 +153,4 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
|||||||
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
||||||
* treated as a defect and fails the stream.
|
* treated as a defect and fails the stream.
|
||||||
*/
|
*/
|
||||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
export class ToolFailure extends Tool.Error {}
|
||||||
message: Schema.String,
|
|
||||||
error: Schema.optional(Schema.Defect()),
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
||||||
}) {}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
|
||||||
import { ModelSchema } from "./options"
|
|
||||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||||
import { ProviderFailureClassification } from "./errors"
|
import { ProviderFailureClassification } from "./errors"
|
||||||
|
|
||||||
@@ -34,21 +33,22 @@ import { ProviderFailureClassification } from "./errors"
|
|||||||
*
|
*
|
||||||
* **Semantics by provider**:
|
* **Semantics by provider**:
|
||||||
*
|
*
|
||||||
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
|
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
|
||||||
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
||||||
* derive the breakdown.
|
* derive the breakdown.
|
||||||
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
|
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||||
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
|
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
* Anthropic's `outputTokens` includes extended thinking. Newer responses
|
||||||
* combined total — a documented limitation of the Anthropic API.
|
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
|
||||||
|
* to `reasoningTokens`; older responses leave it undefined.
|
||||||
*
|
*
|
||||||
* `providerMetadata` always carries the provider's raw usage payload —
|
* `providerMetadata` always carries the provider's raw usage payload —
|
||||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||||
* — for fields we don't normalize and for billing-level audit trails.
|
* — for fields we don't normalize and for billing-level audit trails.
|
||||||
* Matches the same escape-hatch field on `LLMEvent`.
|
* Matches the same escape-hatch field on `LLMEvent`.
|
||||||
*/
|
*/
|
||||||
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||||
inputTokens: Schema.optional(Schema.Number),
|
inputTokens: Schema.optional(Schema.Number),
|
||||||
outputTokens: Schema.optional(Schema.Number),
|
outputTokens: Schema.optional(Schema.Number),
|
||||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||||
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-start"),
|
type: Schema.tag("tool-input-start"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||||
@@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||||
|
|
||||||
|
/** A local tool call whose final input could not be decoded. */
|
||||||
|
export const ToolInputError = Schema.Struct({
|
||||||
|
type: Schema.tag("tool-input-error"),
|
||||||
|
id: ToolCallID,
|
||||||
|
name: Schema.String,
|
||||||
|
raw: Schema.String,
|
||||||
|
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||||
|
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||||
|
|
||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.tag("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
@@ -180,10 +190,16 @@ export const ToolError = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||||
|
|
||||||
|
export const FinishReasonDetails = Schema.Struct({
|
||||||
|
normalized: FinishReason,
|
||||||
|
raw: Schema.optional(Schema.String),
|
||||||
|
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||||
|
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||||
|
|
||||||
export const StepFinish = Schema.Struct({
|
export const StepFinish = Schema.Struct({
|
||||||
type: Schema.tag("step-finish"),
|
type: Schema.tag("step-finish"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
reason: FinishReason,
|
reason: FinishReasonDetails,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||||
@@ -191,7 +207,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
|||||||
|
|
||||||
export const Finish = Schema.Struct({
|
export const Finish = Schema.Struct({
|
||||||
type: Schema.tag("finish"),
|
type: Schema.tag("finish"),
|
||||||
reason: FinishReason,
|
reason: FinishReasonDetails,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||||
@@ -216,6 +232,7 @@ const llmEventTagged = Schema.Union([
|
|||||||
ToolInputStart,
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
ToolInputEnd,
|
ToolInputEnd,
|
||||||
|
ToolInputError,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
@@ -253,6 +270,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
|
||||||
|
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||||
ToolResult.make({
|
ToolResult.make({
|
||||||
@@ -283,6 +302,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
|
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
@@ -293,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
})
|
})
|
||||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||||
|
|
||||||
export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
|
|
||||||
id: Schema.String,
|
|
||||||
route: RouteID,
|
|
||||||
protocol: ProtocolID,
|
|
||||||
model: ModelSchema,
|
|
||||||
body: Schema.Unknown,
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
|
|
||||||
* on `LLMClient.prepare<Body>(...)` when the caller knows which route their
|
|
||||||
* request will resolve to and wants its native shape statically exposed
|
|
||||||
* (debug UIs, request previews, plan rendering).
|
|
||||||
*
|
|
||||||
* The runtime body is identical — the route still emits `body: unknown` — so
|
|
||||||
* this is a type-level assertion the caller makes about what they expect to
|
|
||||||
* find. The prepare runtime does not validate the assertion.
|
|
||||||
*/
|
|
||||||
export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
|
|
||||||
readonly body: Body
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseText = (events: ReadonlyArray<LLMEvent>) =>
|
const responseText = (events: ReadonlyArray<LLMEvent>) =>
|
||||||
events
|
events
|
||||||
.filter(LLMEvent.is.textDelta)
|
.filter(LLMEvent.is.textDelta)
|
||||||
@@ -350,7 +347,7 @@ interface ResponseState {
|
|||||||
readonly events: ReadonlyArray<LLMEvent>
|
readonly events: ReadonlyArray<LLMEvent>
|
||||||
readonly message: Message
|
readonly message: Message
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReasonDetails
|
||||||
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
||||||
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
||||||
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
||||||
@@ -378,7 +375,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
events,
|
events,
|
||||||
finishReason: state.finishReason ?? "error",
|
finishReason: state.finishReason ?? { normalized: "error" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -548,6 +545,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
|||||||
return reduceToolInputDelta(next, event)
|
return reduceToolInputDelta(next, event)
|
||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
return reduceToolInputEnd(next, event)
|
return reduceToolInputEnd(next, event)
|
||||||
|
case "tool-input-error": {
|
||||||
|
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
|
||||||
|
return { ...next, toolInputs }
|
||||||
|
}
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
return reduceToolCall(next, event)
|
return reduceToolCall(next, event)
|
||||||
case "tool-result":
|
case "tool-result":
|
||||||
@@ -561,7 +562,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
|||||||
message: Message,
|
message: Message,
|
||||||
events: Schema.Array(LLMEvent),
|
events: Schema.Array(LLMEvent),
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
finishReason: FinishReason,
|
finishReason: FinishReasonDetails,
|
||||||
}) {
|
}) {
|
||||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||||
get text() {
|
get text() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user