mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9e4663df | |||
| 3f807d8836 | |||
| 4876f416cc | |||
| dbc178c3cc | |||
| 73974dc668 | |||
| 1593479454 | |||
| 971681c567 | |||
| 378a7b7be1 | |||
| 6d57667168 | |||
| d4935360c5 | |||
| 89281dcd01 | |||
| 9f9184b002 | |||
| c6f4fa0d1f | |||
| c505c91438 | |||
| 8887c2ffa9 | |||
| ddaf9ff433 | |||
| d3e5d6d268 | |||
| f9bc948912 | |||
| 6f238feeff | |||
| e867a21ea5 | |||
| 64c0411edb | |||
| 7da10aa65d | |||
| d80b0a1e7e | |||
| 481125f617 | |||
| 59d0d64a6b | |||
| e0cdf1ed09 | |||
| 9e50d76416 | |||
| 771c0f5850 | |||
| b1a0ef91bb | |||
| 2e67cee75b | |||
| 890735c1d7 | |||
| 049f0b0c3b | |||
| 6020f36862 | |||
| 0d24ebdbbe | |||
| b22c182406 | |||
| c7f2f367e3 | |||
| ccd2135e7a | |||
| c7c22b9d7e | |||
| 67e99993f5 | |||
| be6abc02b5 | |||
| e012a57d1d | |||
| 697e3e13cc | |||
| 89451c3e32 | |||
| ad7d515d50 | |||
| 8e71c8425f | |||
| da4ab56801 | |||
| dd780ca882 | |||
| fb9c9a2cbd | |||
| 4c786f0ff8 | |||
| 1def4aa35a | |||
| de11122c96 | |||
| 62e3b6ad78 | |||
| 2c99b549db | |||
| d7c3a95368 | |||
| 3b5b5aee0c | |||
| 8cbfac3184 | |||
| e765349865 | |||
| 750f3384ff | |||
| 1cf61593b5 | |||
| 708e4d8867 | |||
| 0a5349c51c | |||
| 3fd93b659b | |||
| 17a445de3e | |||
| 68e3888bc3 | |||
| 27a53969d6 | |||
| 1e3d3fcaca | |||
| 7420903859 | |||
| 853d3534e5 | |||
| 6cd3da1d4c | |||
| 9692bcbc21 | |||
| 79d5359d11 | |||
| b9ca77c5ff | |||
| 7a72e51710 | |||
| 0fda7d1b33 | |||
| 6ba707d305 | |||
| d144c81d17 | |||
| b8fb894ec7 | |||
| b7167aaab0 | |||
| ad59ccc2e2 | |||
| a82318019f | |||
| e9d58abd99 | |||
| 1ce7e90d3e | |||
| 92658e4389 | |||
| 358a53cb1f | |||
| 54e2eef182 | |||
| 282b644cde | |||
| 4fee1bcf3c | |||
| da850f18da | |||
| 9a716609c6 | |||
| 917051a881 | |||
| 55f490b898 | |||
| b190b778d4 | |||
| 95235df4c8 | |||
| c3f21fb5fb | |||
| f282a22d98 | |||
| a859d48b08 | |||
| 1f60ad6e2f | |||
| bdec7a4f87 | |||
| d35f81ef92 | |||
| d0d0ada4a9 | |||
| beb6216343 | |||
| d9c9c50809 | |||
| a7106ae023 | |||
| 2ae3b82c24 | |||
| 784042202e | |||
| 320da4256d | |||
| 2bbaeb0d4b | |||
| ada4f1bee6 | |||
| 75efc9d833 | |||
| 93e1f383dd | |||
| e4924ebf64 | |||
| 992446e85d | |||
| b94ba27f00 | |||
| 8a2966fdbe | |||
| 4aca1be945 | |||
| 100f23ca99 | |||
| 8f54597ef2 | |||
| 85f32fa0da | |||
| 8062b5455a | |||
| 4566395d42 | |||
| 3c70f6df28 | |||
| 97536add75 | |||
| 6258c1943a | |||
| 0d2c865c93 | |||
| 7032a096bf | |||
| 80ef4f454f | |||
| aa2dd5040f | |||
| 9b17449e88 | |||
| 346689fb43 | |||
| 87d2488c78 | |||
| a1d0f43531 | |||
| 030b2d9543 | |||
| e7177a8764 | |||
| a84b1c15ce | |||
| d3eee25ee2 | |||
| b4fabf5984 | |||
| e33d688428 | |||
| f69f78ec6b | |||
| e2758b9945 | |||
| 7c6ecaaca8 | |||
| c29a7c152d | |||
| fa4c5b26dc | |||
| fb703ede73 | |||
| 2937f0e635 | |||
| 3694149135 | |||
| c4eeefe0f1 | |||
| 667c274c7f | |||
| 2c8e2a2b28 | |||
| 9e9eac81e3 | |||
| 59f0e3a378 | |||
| 656c4cc7fa | |||
| f28c10aa4d | |||
| 97ccafce3b | |||
| 1864bc4161 | |||
| 87ef814190 | |||
| e945ddf80e | |||
| fa1b4ef7ec | |||
| 97d3cd0b3a | |||
| 7fd1eee35a | |||
| 2eecf076c4 | |||
| ed08f0e691 | |||
| 238e1903df | |||
| 77c7a7def7 | |||
| 88788941df | |||
| d633d794c2 | |||
| 08d52be8c2 | |||
| 79d5436d2a | |||
| 9a4bd2ba16 | |||
| e68144cb67 | |||
| e5da5bfab2 | |||
| 3a1fb5ae65 | |||
| b3d6063329 | |||
| 6c3c4bc50f | |||
| 7b349654e3 | |||
| 3d2652d7b9 | |||
| 1dea4b9391 | |||
| 15864304a5 | |||
| e312d261a8 | |||
| 2a83911c7e | |||
| 0eaa04718c | |||
| 2e5ec616d2 | |||
| b2551b4e5d | |||
| e81450809d | |||
| 2524e6be8b | |||
| b58f29a4ef | |||
| 8fec7e0e91 | |||
| 94f9d32040 | |||
| ea3e0dde19 | |||
| b731b11184 | |||
| 8676dcf705 | |||
| 2636797c65 | |||
| e673807e39 | |||
| 9a3a1732f1 | |||
| e03a147b71 | |||
| e461fdc2d0 | |||
| e4178886fa | |||
| 1e6bfaf3d7 | |||
| 876a4a2586 | |||
| 5e77c494c7 | |||
| 9be9dd737c | |||
| 4d22d4e75f | |||
| 8b93bc395d | |||
| e756e497c2 | |||
| 0d2684b673 | |||
| 858caa6848 | |||
| 9a89851cea | |||
| 876459788f | |||
| b0ab1e2992 | |||
| d158f2cd39 | |||
| 9be3aa92b5 | |||
| d19f58c5df | |||
| b7343edaf3 | |||
| b84f5ad2fb | |||
| d993f1b8ed | |||
| f6fcbaad5e | |||
| 5c4f6ef1e3 | |||
| 212139ff95 | |||
| e5eabc446d | |||
| ea43a16b7d | |||
| 960b1ca284 | |||
| 4db272ff64 | |||
| fdc4fd8268 | |||
| 67e87f534e | |||
| 2938ac3298 | |||
| 749d24ebc0 | |||
| 384cff3768 | |||
| 5970537a8a | |||
| 58f909d5b9 | |||
| cc15c2a488 | |||
| ebc2504ef3 | |||
| c33c9bf2b9 | |||
| 2970b7a6a8 | |||
| 82d2c6133e | |||
| 22f2604ffa | |||
| a71884dfdf | |||
| 6f629c2a9d | |||
| 4651bd15de | |||
| ad7ebe84a0 | |||
| 38eeed56cd | |||
| b453c2016c | |||
| cb25ed8cc8 | |||
| 838d747514 | |||
| 879766aee7 | |||
| d6625397d9 | |||
| ab77fb080a | |||
| 2a7d0729d0 | |||
| e2d9376614 | |||
| f288d7e107 | |||
| afe4c5d23a | |||
| 98ad4465f8 | |||
| 5b1e8450e7 | |||
| 99490f289b | |||
| e37c7be434 | |||
| 39f4adb4dc | |||
| 393b43a881 | |||
| 394b5ac5fd | |||
| dbc7d0ee09 | |||
| 08f26a2d2e | |||
| a51622a0e7 | |||
| 20ff543ff2 | |||
| f43474043a | |||
| 5a0ba34d64 | |||
| 9a1de86d9c | |||
| ea7fa43243 | |||
| 730e1935cf | |||
| d6deed6752 | |||
| 1d89e911e8 | |||
| c85b09de6f | |||
| 30db9dd86e | |||
| b6966177fa | |||
| 6b09b9e6a2 | |||
| 3876f7aad6 | |||
| d912202cf2 | |||
| f8c46684eb | |||
| c4afbc4aae | |||
| 98a9d864e6 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Apply shared Session model-request preparation to transient generation.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/client": patch
|
||||||
|
"@opencode-ai/plugin": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: deploy-posts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- v2
|
||||||
|
paths:
|
||||||
|
- packages/posts/**
|
||||||
|
- bun.lock
|
||||||
|
- .github/workflows/deploy-posts.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-posts-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: packages/posts
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
working-directory: packages/posts
|
||||||
|
run: bun run deploy
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
@@ -27,7 +27,6 @@ jobs:
|
|||||||
working-directory: packages/www
|
working-directory: packages/www
|
||||||
run: bun run build
|
run: bun run build
|
||||||
env:
|
env:
|
||||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
|
||||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
with:
|
with:
|
||||||
bun-version: canary # Bun 1.4 until its stable release is published
|
bun-version: 1.4.0
|
||||||
|
|
||||||
- name: Setup git committer
|
- name: Setup git committer
|
||||||
id: committer
|
id: committer
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
id: build
|
id: build
|
||||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
env:
|
env:
|
||||||
BUN_COMPILE_RELEASE: canary
|
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
@@ -195,9 +195,33 @@ jobs:
|
|||||||
path: packages/cli/dist/cli-*
|
path: packages/cli/dist/cli-*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build-node-cli:
|
build-node-app-archive:
|
||||||
needs: version
|
needs: version
|
||||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
timeout-minutes: 30
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build app archive
|
||||||
|
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: opencode-node-app-archive
|
||||||
|
path: packages/cli/.cache/app-archive.bin
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-node-cli:
|
||||||
|
needs:
|
||||||
|
- version
|
||||||
|
- build-node-app-archive
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -210,6 +234,7 @@ jobs:
|
|||||||
host: macos-26
|
host: macos-26
|
||||||
- target: windows-arm64
|
- target: windows-arm64
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
bun_install_flags: --cpu=*
|
||||||
- target: windows-x64
|
- target: windows-x64
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
@@ -221,14 +246,19 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
with:
|
with:
|
||||||
install-flags: --os=* --cpu=*
|
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "26.4.0"
|
node-version: "26.4.0"
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with:
|
||||||
|
name: opencode-node-app-archive
|
||||||
|
path: packages/cli/.cache
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
@@ -543,6 +573,7 @@ jobs:
|
|||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
- sign-cli-macos
|
- sign-cli-macos
|
||||||
|
- build-node-app-archive
|
||||||
- build-node-cli
|
- build-node-cli
|
||||||
- sign-cli-windows
|
- sign-cli-windows
|
||||||
- build-electron
|
- build-electron
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Test Effect simplification rules
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: bun run test:effect-simplification-rules
|
||||||
|
|
||||||
|
- name: Check Effect simplifications
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: bun run lint:effect-simplifications
|
||||||
|
|
||||||
- name: Configure git identity
|
- name: Configure git identity
|
||||||
run: |
|
run: |
|
||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
@@ -76,6 +84,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Verify published codemode package
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
working-directory: packages/codemode
|
||||||
|
run: bun run script/publish.ts --dry-run
|
||||||
|
|
||||||
- name: Verify compiled service lifecycle
|
- name: Verify compiled service lifecycle
|
||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-17
@@ -1,19 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/tui.json",
|
"$schema": "https://opencode.ai/tui.json"
|
||||||
"plugin": [
|
|
||||||
[
|
|
||||||
"./plugins/tui-smoke.tsx",
|
|
||||||
{
|
|
||||||
"enabled": false,
|
|
||||||
"label": "workspace",
|
|
||||||
"keybinds": {
|
|
||||||
"smoke_modal": "ctrl+alt+m",
|
|
||||||
"smoke_screen": "ctrl+alt+o",
|
|
||||||
"smoke_screen_home": "escape,ctrl+shift+h",
|
|
||||||
"smoke_screen_modal": "ctrl+alt+m",
|
|
||||||
"smoke_dialog_close": "escape,q"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||||
- The default branch in this repo is `v2`.
|
- The default branch in this repo is `v2`.
|
||||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||||
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
|
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
|
||||||
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
|
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
|
||||||
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
|
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
|
||||||
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
|
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -15,42 +15,44 @@
|
|||||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||||
"dev:www": "bun run --cwd packages/www dev",
|
"dev:www": "bun run --cwd packages/www dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
|
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||||
|
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||||
|
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||||
"translate:app": "bun run script/translate-app.ts",
|
|
||||||
"test": "echo 'do not run tests from root' && exit 1"
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/stats/*",
|
"packages/stats/*"
|
||||||
"packages/slack"
|
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||||
"@effect/platform-node": "4.0.0-beta.107",
|
"@effect/platform-node": "4.0.0-rc.111",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.5.4",
|
"@opentui/core": "0.5.7",
|
||||||
"@opentui/keymap": "0.5.4",
|
"@opentui/keymap": "0.5.7",
|
||||||
"@opentui/solid": "0.5.4",
|
"@opentui/solid": "0.5.7",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.37",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -69,9 +71,9 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||||
"effect": "4.0.0-beta.107",
|
"effect": "4.0.0-rc.111",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -127,7 +129,6 @@
|
|||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.5",
|
|
||||||
"heap-snapshot-toolkit": "1.1.3",
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
@@ -165,7 +166,6 @@
|
|||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-11
@@ -71,7 +71,7 @@ export const route = Route.make({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||||
|
|
||||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
|||||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
|
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const openai = OpenAI.configure({ apiKey, baseURL, store: false })
|
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||||
const model = openai.responses("gpt-4o-mini")
|
const model = openai.responses("gpt-4o-mini")
|
||||||
|
|
||||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||||
@@ -108,22 +108,17 @@ Keep provider facades small and explicit:
|
|||||||
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
||||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||||
|
|
||||||
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
|
|
||||||
|
|
||||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||||
|
|
||||||
### Provider Package Entrypoints
|
### Provider Package Entrypoints
|
||||||
|
|
||||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
|
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||||
|
|
||||||
const selected = model({
|
const selected = model("gpt-5", {
|
||||||
id: "gpt-5",
|
apiKey,
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: apiKey },
|
|
||||||
defaults: {},
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -218,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
|||||||
- Input failed the `parameters` Schema.
|
- Input failed the `parameters` Schema.
|
||||||
- The handler returned a `ToolFailure`.
|
- The handler returned a `ToolFailure`.
|
||||||
|
|
||||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||||
|
|
||||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||||
|
|||||||
+11
-56
@@ -305,26 +305,18 @@ const gateway = CloudflareAIGateway.configure({
|
|||||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||||
```
|
```
|
||||||
|
|
||||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider.
|
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||||
|
|
||||||
### Package-like entrypoints
|
### Package-like entrypoints
|
||||||
|
|
||||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
|
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||||
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY
|
const selected = model("gpt-5", {
|
||||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required")
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
headers: { "x-application": "opencode" },
|
||||||
const selected = model({
|
|
||||||
id: "gpt-5",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: apiKey },
|
|
||||||
defaults: {
|
|
||||||
headers: { "x-application": "opencode" },
|
|
||||||
limits: { context: 200_000, output: 64_000 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -348,57 +340,30 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
|
|||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||||
|
|
||||||
model({
|
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||||
id: "gemini-3.5-flash",
|
|
||||||
settings: { project: "my-project", location: "global" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||||
|
|
||||||
model({
|
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
|
||||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
|
||||||
settings: { project: "my-project", location: "global" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||||
|
|
||||||
model({
|
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||||
id: "xai/grok-4.20-reasoning",
|
|
||||||
settings: { project: "my-project", location: "global" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||||
|
|
||||||
model({
|
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
settings: { project: "my-project", location: "global" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
|
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||||
|
|
||||||
## How OpenCode uses this package
|
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||||
|
|
||||||
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
|
|
||||||
|
|
||||||
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
|
|
||||||
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
|
|
||||||
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
|
|
||||||
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
|
|
||||||
|
|
||||||
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
|
|
||||||
|
|
||||||
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
|
|
||||||
|
|
||||||
## Provider options & HTTP overlays
|
## Provider options & HTTP overlays
|
||||||
|
|
||||||
@@ -411,16 +376,6 @@ Request options in order of stability:
|
|||||||
|
|
||||||
Route/provider defaults are overridden by request-level values for each axis.
|
Route/provider defaults are overridden by request-level values for each axis.
|
||||||
|
|
||||||
Provider-specific facades accept their own options directly because the provider is already known:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const model = OpenAI.configure({
|
|
||||||
apiKey,
|
|
||||||
store: false,
|
|
||||||
reasoningEffort: "high",
|
|
||||||
}).responses("gpt-5")
|
|
||||||
```
|
|
||||||
|
|
||||||
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
|
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import { OpenAI } from "@opencode-ai/ai/providers"
|
|||||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||||
|
|
||||||
// 1. Pick a model. The provider helper records provider identity, protocol
|
// 1. Pick a model. The provider helper records provider identity, protocol
|
||||||
// choice, deployment options, authentication, and defaults. Catalog capabilities
|
// choice, capabilities, deployment options, authentication, and defaults.
|
||||||
// remain application-owned and are not part of LanguageModel.
|
|
||||||
const model = OpenAI.configure({
|
const model = OpenAI.configure({
|
||||||
apiKey,
|
apiKey,
|
||||||
generation: { maxTokens: 160 },
|
generation: { maxTokens: 160 },
|
||||||
store: false,
|
providerOptions: {
|
||||||
|
store: false,
|
||||||
|
},
|
||||||
}).model("gpt-4o-mini")
|
}).model("gpt-4o-mini")
|
||||||
|
|
||||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||||
@@ -73,8 +74,8 @@ const streamText = LLM.stream(request).pipe(
|
|||||||
Stream.runDrain,
|
Stream.runDrain,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
|
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
|
||||||
// advertise definitions on the request, stream one call, dispatch local calls,
|
// advertise definitions on the request, stream one turn, dispatch local calls,
|
||||||
// then persist/build follow-up history in the enclosing product flow.
|
// then persist/build follow-up history in the enclosing product flow.
|
||||||
const tools = {
|
const tools = {
|
||||||
get_weather: Tool.make({
|
get_weather: Tool.make({
|
||||||
@@ -101,7 +102,7 @@ const streamWithTools = Effect.gen(function* () {
|
|||||||
console.log("tool result", event.name, dispatched.result)
|
console.log("tool result", event.name, dispatched.result)
|
||||||
|
|
||||||
// A durable agent would persist these messages before starting another
|
// A durable agent would persist these messages before starting another
|
||||||
// model call. This tutorial keeps the boundary visible instead.
|
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||||
const followUp = LLMRequest.update(request, {
|
const followUp = LLMRequest.update(request, {
|
||||||
messages: [
|
messages: [
|
||||||
...request.messages,
|
...request.messages,
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
|
|||||||
response: HttpClientResponse.HttpClientResponse,
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
) {
|
) {
|
||||||
if (response.status >= 200 && response.status < 300) return undefined
|
if (response.status >= 200 && response.status < 300) return undefined
|
||||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
||||||
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
|
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ export type {
|
|||||||
LanguageModelOptions as ProviderLanguageModelOptions,
|
LanguageModelOptions as ProviderLanguageModelOptions,
|
||||||
} from "./provider.js"
|
} from "./provider.js"
|
||||||
export type {
|
export type {
|
||||||
Credential as ProviderPackageCredential,
|
|
||||||
Defaults as ProviderPackageDefaults,
|
|
||||||
Definition as ProviderPackageDefinition,
|
Definition as ProviderPackageDefinition,
|
||||||
ModelInput as ProviderPackageModelInput,
|
|
||||||
Settings as ProviderPackageSettings,
|
Settings as ProviderPackageSettings,
|
||||||
} from "./provider-package.js"
|
} from "./provider-package.js"
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ import { ToolStream } from "./utils/tool-stream.js"
|
|||||||
const ADAPTER = "anthropic-messages"
|
const ADAPTER = "anthropic-messages"
|
||||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||||
export const PATH = "/messages"
|
export const PATH = "/messages"
|
||||||
|
export const DEFAULT_MAX_TOKENS = 32_000
|
||||||
|
|
||||||
|
const SSE_EVENTS = new Set([
|
||||||
|
"message",
|
||||||
|
"message_start",
|
||||||
|
"message_delta",
|
||||||
|
"message_stop",
|
||||||
|
"content_block_start",
|
||||||
|
"content_block_delta",
|
||||||
|
"content_block_stop",
|
||||||
|
"ping",
|
||||||
|
"error",
|
||||||
|
])
|
||||||
|
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||||
|
|
||||||
export type ThinkingInput =
|
export type ThinkingInput =
|
||||||
| {
|
| {
|
||||||
@@ -40,7 +54,7 @@ export type ThinkingInput =
|
|||||||
| {
|
| {
|
||||||
readonly type: "disabled"
|
readonly type: "disabled"
|
||||||
}
|
}
|
||||||
| ({ readonly type: "enabled" } & (
|
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
|
||||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||||
))
|
))
|
||||||
@@ -199,6 +213,7 @@ const AnthropicThinking = Schema.Union([
|
|||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("enabled"),
|
type: Schema.tag("enabled"),
|
||||||
budget_tokens: Schema.Number,
|
budget_tokens: Schema.Number,
|
||||||
|
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("adaptive"),
|
type: Schema.tag("adaptive"),
|
||||||
@@ -233,7 +248,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
|
|||||||
|
|
||||||
const AnthropicUsage = Schema.StructWithRest(
|
const AnthropicUsage = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
input_tokens: Schema.optional(Schema.Number),
|
input_tokens: optionalNull(Schema.Number),
|
||||||
output_tokens: Schema.optional(Schema.Number),
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||||
@@ -361,16 +376,18 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
|||||||
tool: (name) => ({ type: "tool" as const, name }),
|
tool: (name) => ({ type: "tool" as const, name }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
|
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: part.id,
|
id: scrubToolCallID(part.id),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
input: part.input,
|
input: part.input,
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
|
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
|
||||||
type: "server_tool_use",
|
type: "server_tool_use",
|
||||||
id: part.id,
|
id: scrubToolCallID(part.id),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
input: part.input,
|
input: part.input,
|
||||||
})
|
})
|
||||||
@@ -392,7 +409,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
|||||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||||
// histories constructed directly from provider events.
|
// histories constructed directly from provider events.
|
||||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||||
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||||
@@ -574,7 +591,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
|
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
|
||||||
content.push({
|
content.push({
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
tool_use_id: part.id,
|
tool_use_id: scrubToolCallID(part.id),
|
||||||
content: yield* lowerToolResultContent(part),
|
content: yield* lowerToolResultContent(part),
|
||||||
is_error: part.result.type === "error" ? true : undefined,
|
is_error: part.result.type === "error" ? true : undefined,
|
||||||
cache_control: cacheControl(breakpoints, part.cache),
|
cache_control: cacheControl(breakpoints, part.cache),
|
||||||
@@ -599,15 +616,12 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
|||||||
|
|
||||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||||
if (!ProviderShared.isRecord(input)) return undefined
|
if (!ProviderShared.isRecord(input)) return undefined
|
||||||
if (input.type === "adaptive") {
|
const display =
|
||||||
const display =
|
input.display === "summarized" || input.display === "omitted"
|
||||||
input.display === "summarized"
|
? (input.display as "summarized" | "omitted")
|
||||||
? ("summarized" as const)
|
: undefined
|
||||||
: input.display === "omitted"
|
if (input.type === "adaptive")
|
||||||
? ("omitted" as const)
|
|
||||||
: undefined
|
|
||||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||||
}
|
|
||||||
if (input.type === "disabled") return { type: "disabled" as const }
|
if (input.type === "disabled") return { type: "disabled" as const }
|
||||||
if (input.type !== "enabled") return undefined
|
if (input.type !== "enabled") return undefined
|
||||||
const budget =
|
const budget =
|
||||||
@@ -618,13 +632,12 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
|||||||
: undefined
|
: undefined
|
||||||
if (budget === undefined)
|
if (budget === undefined)
|
||||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||||
return { type: "enabled" as const, budget_tokens: budget }
|
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
|
||||||
})
|
})
|
||||||
|
|
||||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
|
||||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||||
@@ -663,7 +676,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
tools,
|
tools,
|
||||||
tool_choice: toolChoice,
|
tool_choice: toolChoice,
|
||||||
stream: true as const,
|
stream: true as const,
|
||||||
max_tokens: generation?.maxTokens ?? outputLimit,
|
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
top_k: generation?.topK,
|
top_k: generation?.topK,
|
||||||
@@ -692,7 +705,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const nonCached = usage.input_tokens
|
const nonCached = usage.input_tokens ?? undefined
|
||||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||||
@@ -780,7 +793,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
const block = event.content_block
|
const block = event.content_block
|
||||||
if (!block) return [state, NO_EVENTS]
|
if (!block) return [state, NO_EVENTS]
|
||||||
|
|
||||||
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
|
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||||
|
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
return [
|
return [
|
||||||
@@ -788,7 +802,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
...state,
|
...state,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
tools: ToolStream.start(state.tools, event.index, {
|
tools: ToolStream.start(state.tools, event.index, {
|
||||||
id: block.id ?? String(event.index),
|
id: block.id,
|
||||||
name: block.name ?? "",
|
name: block.name ?? "",
|
||||||
input:
|
input:
|
||||||
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||||
@@ -800,7 +814,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
[
|
[
|
||||||
...events,
|
...events,
|
||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: block.id ?? String(event.index),
|
id: block.id,
|
||||||
name: block.name ?? "",
|
name: block.name ?? "",
|
||||||
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -990,15 +1004,28 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onError = (event: AnthropicEvent) =>
|
const onError = (event: AnthropicEvent) =>
|
||||||
new AIError({
|
Effect.fail(
|
||||||
module: ADAPTER,
|
new AIError({
|
||||||
method: "stream",
|
module: ADAPTER,
|
||||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
method: "stream",
|
||||||
})
|
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||||
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
|
if (event.type === "content_block_start") {
|
||||||
|
const block = event.content_block
|
||||||
|
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
|
||||||
|
if (event.index === undefined)
|
||||||
|
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
|
||||||
|
if (!block.id)
|
||||||
|
return Effect.fail(
|
||||||
|
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Effect.succeed(onContentBlockStart(state, event))
|
||||||
|
}
|
||||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||||
@@ -1039,7 +1066,7 @@ export const route = Route.make({
|
|||||||
protocol,
|
protocol,
|
||||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||||
auth: Auth.none,
|
auth: Auth.none,
|
||||||
framing: Framing.sse,
|
framing,
|
||||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from "../schema/index.js"
|
} from "../schema/index.js"
|
||||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||||
import { Lifecycle } from "./utils/lifecycle.js"
|
import { Lifecycle } from "./utils/lifecycle.js"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||||
@@ -37,6 +37,17 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
|||||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
|
||||||
|
// so their tool-result attachments lower as a separate user turn instead.
|
||||||
|
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||||
|
|
||||||
|
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||||
|
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||||
|
const omitsFunctionCallIds = (modelID: string) => {
|
||||||
|
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||||
|
return match !== null && Number(match[1]) < 3
|
||||||
|
}
|
||||||
|
|
||||||
export interface OptionsInput {
|
export interface OptionsInput {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
readonly cachedContent?: string
|
readonly cachedContent?: string
|
||||||
@@ -71,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Request Body Schema
|
// Request Body Schema
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
// Gemini is known to send explicit `null` for optional streaming fields
|
||||||
|
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||||
|
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||||
|
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||||
|
// so the shared schemas stay safe there.
|
||||||
const GeminiTextPart = Schema.Struct({
|
const GeminiTextPart = Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
thought: Schema.optional(Schema.Boolean),
|
thought: optionalNull(Schema.Boolean),
|
||||||
thoughtSignature: Schema.optional(Schema.String),
|
thoughtSignature: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
const GeminiInlineDataPart = Schema.Struct({
|
const GeminiInlineDataPart = Schema.Struct({
|
||||||
@@ -87,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
|||||||
|
|
||||||
const GeminiFunctionCallPart = Schema.Struct({
|
const GeminiFunctionCallPart = Schema.Struct({
|
||||||
functionCall: Schema.Struct({
|
functionCall: Schema.Struct({
|
||||||
id: Schema.optional(Schema.String),
|
id: optionalNull(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
args: Schema.optional(Schema.Unknown),
|
args: Schema.optional(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
thoughtSignature: Schema.optional(Schema.String),
|
thoughtSignature: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
const GeminiFunctionResponsePart = Schema.Struct({
|
const GeminiFunctionResponsePart = Schema.Struct({
|
||||||
@@ -111,8 +127,8 @@ const GeminiContentPart = Schema.Union([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const GeminiContent = Schema.Struct({
|
const GeminiContent = Schema.Struct({
|
||||||
role: Schema.Literals(["user", "model"]),
|
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||||
parts: Schema.Array(GeminiContentPart),
|
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||||
})
|
})
|
||||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||||
|
|
||||||
@@ -175,44 +191,45 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
|||||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||||
|
|
||||||
const GeminiUsage = Schema.Struct({
|
const GeminiUsage = Schema.Struct({
|
||||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||||
promptTokenCount: Schema.optional(Schema.Number),
|
promptTokenCount: optionalNull(Schema.Number),
|
||||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
candidatesTokenCount: optionalNull(Schema.Number),
|
||||||
totalTokenCount: Schema.optional(Schema.Number),
|
totalTokenCount: optionalNull(Schema.Number),
|
||||||
})
|
})
|
||||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||||
|
|
||||||
const GeminiCandidate = Schema.Struct({
|
const GeminiCandidate = Schema.Struct({
|
||||||
content: Schema.optional(GeminiContent),
|
content: optionalNull(GeminiContent),
|
||||||
finishReason: Schema.optional(Schema.String),
|
finishReason: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
blockReason: Schema.optional(Schema.String),
|
blockReason: optionalNull(Schema.String),
|
||||||
blockReasonMessage: Schema.optional(Schema.String),
|
blockReasonMessage: optionalNull(Schema.String),
|
||||||
safetyRatings: Schema.optional(Schema.Unknown),
|
safetyRatings: optionalNull(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
)
|
)
|
||||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||||
|
|
||||||
const GeminiEvent = Schema.Struct({
|
const GeminiEvent = Schema.Struct({
|
||||||
candidates: optionalArray(GeminiCandidate),
|
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||||
usageMetadata: Schema.optional(GeminiUsage),
|
usageMetadata: optionalNull(GeminiUsage),
|
||||||
})
|
})
|
||||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||||
|
|
||||||
interface ParserState {
|
interface ParserState {
|
||||||
readonly finishReason?: string
|
readonly finishReason?: string
|
||||||
readonly hasToolCalls: boolean
|
readonly hasToolCalls: boolean
|
||||||
readonly nextToolCallId: number
|
|
||||||
readonly promptFeedback?: GeminiPromptFeedback
|
readonly promptFeedback?: GeminiPromptFeedback
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningSignature?: string
|
readonly reasoningSignature?: string
|
||||||
|
readonly textSignature?: string
|
||||||
|
readonly seenCallIds?: ReadonlySet<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -270,27 +287,31 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||||
const google = providerMetadata?.google
|
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
|
||||||
? google.functionCallId
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart) => ({
|
|
||||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
|
||||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||||
const contents: GeminiContent[] = []
|
const contents: GeminiContent[] = []
|
||||||
|
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||||
|
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||||
|
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||||
|
const flushMedia = () => {
|
||||||
|
if (!pendingMedia) return
|
||||||
|
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
|
||||||
|
pendingMedia = undefined
|
||||||
|
}
|
||||||
|
|
||||||
for (const message of request.messages) {
|
for (const message of request.messages) {
|
||||||
|
if (message.role !== "tool") flushMedia()
|
||||||
if (message.role === "system") {
|
if (message.role === "system") {
|
||||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||||
const previous = contents.at(-1)
|
const previous = contents.at(-1)
|
||||||
if (previous?.role === "user")
|
// Gemini rejects a continuation whose function-response turn carries extra
|
||||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
// parts, so an update after a tool result starts its own user turn.
|
||||||
|
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||||
|
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -314,7 +335,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
parts.push({ text: part.text })
|
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
@@ -322,7 +343,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
const lowered = lowerToolCall(part)
|
const lowered = lowerToolCall(part, omitCallIds)
|
||||||
const signature = lowered.thoughtSignature
|
const signature = lowered.thoughtSignature
|
||||||
parts.push({
|
parts.push({
|
||||||
...lowered,
|
...lowered,
|
||||||
@@ -347,7 +368,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
if (part.result.type !== "content") {
|
if (part.result.type !== "content") {
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
id: functionCallId(part.providerMetadata),
|
...(omitCallIds ? {} : { id: part.id }),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
@@ -365,21 +386,28 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
|||||||
const value = ProviderShared.normalizeToolFile(item)
|
const value = ProviderShared.normalizeToolFile(item)
|
||||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||||
}
|
}
|
||||||
|
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||||
parts.push({
|
parts.push({
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
id: functionCallId(part.providerMetadata),
|
...(omitCallIds ? {} : { id: part.id }),
|
||||||
name: part.name,
|
name: part.name,
|
||||||
response: {
|
response: {
|
||||||
name: part.name,
|
name: part.name,
|
||||||
content: text.join("\n"),
|
content: text.join("\n"),
|
||||||
},
|
},
|
||||||
parts: media.length > 0 ? media : undefined,
|
parts: legacyToolMedia || media.length === 0 ? undefined : media,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
contents.push({ role: "user", parts })
|
// Gemini requires every response to a parallel call batch in one user turn,
|
||||||
|
// so consecutive tool results join the open function-response turn.
|
||||||
|
const previous = contents.at(-1)
|
||||||
|
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||||
|
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||||
|
else contents.push({ role: "user", parts })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushMedia()
|
||||||
return contents
|
return contents
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -465,21 +493,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const cached = usage.cachedContentTokenCount
|
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
// token arithmetic below treats them like absent counts.
|
||||||
|
const promptTokens = usage.promptTokenCount ?? undefined
|
||||||
|
const cached = usage.cachedContentTokenCount ?? undefined
|
||||||
|
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||||
|
const visible = usage.candidatesTokenCount ?? undefined
|
||||||
|
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||||
// when the visible component is reported — otherwise we'd fabricate an
|
// when the visible component is reported — otherwise we'd fabricate an
|
||||||
// inclusive number from a partial breakdown.
|
// inclusive number from a partial breakdown.
|
||||||
const outputTokens =
|
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
|
||||||
return new Usage({
|
return new Usage({
|
||||||
inputTokens: usage.promptTokenCount,
|
inputTokens: promptTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
nonCachedInputTokens: nonCached,
|
nonCachedInputTokens: nonCached,
|
||||||
cacheReadInputTokens: cached,
|
cacheReadInputTokens: cached,
|
||||||
reasoningTokens: usage.thoughtsTokenCount,
|
reasoningTokens: thoughts,
|
||||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||||
providerMetadata: { google: usage },
|
providerMetadata: { google: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -514,19 +546,24 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||||
|
// the "nothing to finish" check below keeps its meaning.
|
||||||
|
const promptBlockReason =
|
||||||
|
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||||
const finishReason = state.finishReason ?? promptBlockReason
|
const finishReason = state.finishReason ?? promptBlockReason
|
||||||
if (finishReason === undefined && state.usage === undefined) return []
|
if (finishReason === undefined && state.usage === undefined) return []
|
||||||
|
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = state.reasoningSignature
|
let lifecycle = state.lifecycle
|
||||||
? Lifecycle.reasoningEnd(
|
if (state.reasoningSignature !== undefined)
|
||||||
state.lifecycle,
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
events,
|
lifecycle,
|
||||||
"reasoning-0",
|
events,
|
||||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
"reasoning-0",
|
||||||
)
|
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||||
: state.lifecycle
|
)
|
||||||
|
if (state.textSignature !== undefined)
|
||||||
|
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
|
||||||
Lifecycle.finish(lifecycle, events, {
|
Lifecycle.finish(lifecycle, events, {
|
||||||
reason: {
|
reason: {
|
||||||
normalized:
|
normalized:
|
||||||
@@ -556,12 +593,17 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
let hasToolCalls = nextState.hasToolCalls
|
let hasToolCalls = nextState.hasToolCalls
|
||||||
let lifecycle = nextState.lifecycle
|
let lifecycle = nextState.lifecycle
|
||||||
let nextToolCallId = nextState.nextToolCallId
|
|
||||||
let reasoningSignature = nextState.reasoningSignature
|
let reasoningSignature = nextState.reasoningSignature
|
||||||
|
let textSignature = nextState.textSignature
|
||||||
|
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||||
|
const seenCallIds = new Set(nextState.seenCallIds)
|
||||||
|
|
||||||
for (const part of candidate.content.parts) {
|
for (const part of candidate.content.parts ?? []) {
|
||||||
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||||
reasoningSignature = part.thoughtSignature
|
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||||
|
// each block kind must retain the signature attached to its own parts.
|
||||||
|
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
|
||||||
|
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||||
if ("text" in part && part.text.length > 0) {
|
if ("text" in part && part.text.length > 0) {
|
||||||
if (part.thought) {
|
if (part.thought) {
|
||||||
lifecycle = Lifecycle.reasoningDelta(
|
lifecycle = Lifecycle.reasoningDelta(
|
||||||
@@ -569,7 +611,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
events,
|
events,
|
||||||
"reasoning-0",
|
"reasoning-0",
|
||||||
part.text,
|
part.text,
|
||||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -579,17 +621,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
"reasoning-0",
|
"reasoning-0",
|
||||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||||
)
|
)
|
||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
lifecycle = Lifecycle.textDelta(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"text-0",
|
||||||
|
part.text,
|
||||||
|
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
|
||||||
|
)
|
||||||
|
textSignature = undefined
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||||
const metadata = {
|
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||||
}
|
const supplied = part.functionCall.id ?? undefined
|
||||||
|
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||||
|
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||||
|
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||||
lifecycle = Lifecycle.reasoningEnd(
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
lifecycle,
|
lifecycle,
|
||||||
events,
|
events,
|
||||||
@@ -602,7 +654,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
id,
|
id,
|
||||||
name: part.functionCall.name,
|
name: part.functionCall.name,
|
||||||
input,
|
input,
|
||||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
providerMetadata:
|
||||||
|
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
hasToolCalls = true
|
hasToolCalls = true
|
||||||
@@ -614,8 +667,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
...nextState,
|
...nextState,
|
||||||
hasToolCalls,
|
hasToolCalls,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
nextToolCallId,
|
|
||||||
reasoningSignature,
|
reasoningSignature,
|
||||||
|
textSignature,
|
||||||
|
seenCallIds,
|
||||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
@@ -637,7 +691,7 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(GeminiEvent),
|
event: Protocol.jsonEvent(GeminiEvent),
|
||||||
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
|
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
|
||||||
step,
|
step,
|
||||||
onHalt: finish,
|
onHalt: finish,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
|||||||
export * as OpenAIResponses from "./openai-responses.js"
|
export * as OpenAIResponses from "./openai-responses.js"
|
||||||
export * as OpenResponses from "./open-responses.js"
|
export * as OpenResponses from "./open-responses.js"
|
||||||
export * as OpenResponsesChannel from "./open-responses-channel.js"
|
export * as OpenResponsesChannel from "./open-responses-channel.js"
|
||||||
|
export * as XAIResponses from "./xai-responses.js"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../route/transport/index.js"
|
} from "../route/transport/index.js"
|
||||||
import * as ProviderShared from "./shared.js"
|
import * as ProviderShared from "./shared.js"
|
||||||
import { OpenResponses } from "./open-responses.js"
|
import { OpenResponses } from "./open-responses.js"
|
||||||
|
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
|
||||||
|
|
||||||
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
|
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
|
||||||
Schema.Record(Schema.String, Schema.Unknown),
|
Schema.Record(Schema.String, Schema.Unknown),
|
||||||
@@ -22,12 +23,9 @@ export interface Options {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly rotateAfterMs?: number
|
readonly rotateAfterMs?: number
|
||||||
|
readonly enabled?: (url: string) => boolean
|
||||||
|
readonly url?: (url: string) => string
|
||||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||||
readonly driver?: (input: {
|
|
||||||
readonly request: Readonly<Record<string, unknown>>
|
|
||||||
readonly message: string
|
|
||||||
readonly base: WebSocketChannelDriver
|
|
||||||
}) => WebSocketChannelDriver
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Prepared {
|
export interface Prepared {
|
||||||
@@ -114,6 +112,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
|||||||
responseID = created
|
responseID = created
|
||||||
return { type: "frame", frame }
|
return { type: "frame", frame }
|
||||||
}
|
}
|
||||||
|
// Keepalives carry no response state and may arrive before response.created.
|
||||||
|
if (event.type === "keepalive") return { type: "frame", frame }
|
||||||
if (!responseID)
|
if (!responseID)
|
||||||
return yield* ProviderShared.eventError(
|
return yield* ProviderShared.eventError(
|
||||||
options.id,
|
options.id,
|
||||||
@@ -147,18 +147,25 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parts = yield* HttpTransport.jsonRequestParts(input)
|
const parts = yield* HttpTransport.jsonRequestParts(input)
|
||||||
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
|
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
|
||||||
const channel = input.webSocket
|
const channel =
|
||||||
? yield* Effect.gen(function* () {
|
input.webSocket && (options.enabled?.(parts.url) ?? true)
|
||||||
const create = yield* message(parts.jsonBody)
|
? yield* Effect.gen(function* () {
|
||||||
const base = driver(options, create.message)
|
const create = yield* message(parts.jsonBody)
|
||||||
return {
|
const base = driver(options, create.message)
|
||||||
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
|
return {
|
||||||
headers,
|
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||||
rotateAfterMs: options.rotateAfterMs,
|
headers,
|
||||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
rotateAfterMs: options.rotateAfterMs,
|
||||||
}
|
driver: OpenResponsesContinuation.driver({
|
||||||
})
|
id: options.id,
|
||||||
: undefined
|
name: options.name,
|
||||||
|
request: create.request,
|
||||||
|
message: create.message,
|
||||||
|
base,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
http: {
|
http: {
|
||||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
|
|||||||
import * as ProviderShared from "./shared.js"
|
import * as ProviderShared from "./shared.js"
|
||||||
import { OpenResponses } from "./open-responses.js"
|
import { OpenResponses } from "./open-responses.js"
|
||||||
|
|
||||||
const PROTOCOL = "openai-responses.websocket.v1"
|
const PROTOCOL = "open-responses.websocket.v1"
|
||||||
const VERSION = 1
|
const VERSION = 1
|
||||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||||
|
|
||||||
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpenAIResponsesChannel = { driver } as const
|
export const OpenResponsesContinuation = { driver } as const
|
||||||
@@ -5,6 +5,7 @@ import { Protocol } from "../route/protocol.js"
|
|||||||
import {
|
import {
|
||||||
AIError,
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
ProviderInternalReason,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
@@ -42,8 +43,12 @@ const OpenResponsesInputImage = Schema.Struct({
|
|||||||
const OpenResponsesInputFile = Schema.Struct({
|
const OpenResponsesInputFile = Schema.Struct({
|
||||||
type: Schema.tag("input_file"),
|
type: Schema.tag("input_file"),
|
||||||
filename: Schema.String,
|
filename: Schema.String,
|
||||||
file_data: Schema.String,
|
file_data: Schema.optional(Schema.String),
|
||||||
mime_type: Schema.optional(Schema.String),
|
file_url: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
const OpenResponsesInputVideo = Schema.Struct({
|
||||||
|
type: Schema.tag("input_video"),
|
||||||
|
video_url: Schema.String,
|
||||||
})
|
})
|
||||||
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
|
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
|
||||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||||
@@ -54,9 +59,14 @@ const OpenResponsesOutputText = Schema.Struct({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||||
|
|
||||||
|
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||||
|
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||||
type: Schema.tag("summary_text"),
|
type: Schema.tag("summary_text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
@@ -81,6 +91,7 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
|
|||||||
OpenResponsesInputText,
|
OpenResponsesInputText,
|
||||||
OpenResponsesInputImage,
|
OpenResponsesInputImage,
|
||||||
OpenResponsesInputFile,
|
OpenResponsesInputFile,
|
||||||
|
OpenResponsesInputVideo,
|
||||||
])
|
])
|
||||||
|
|
||||||
const OpenResponsesFunctionCallOutput = Schema.Union([
|
const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||||
@@ -93,6 +104,8 @@ export const InputItem = Schema.Union([
|
|||||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
|
type: Schema.tag("message"),
|
||||||
|
id: Schema.optionalKey(Schema.String),
|
||||||
role: Schema.tag("assistant"),
|
role: Schema.tag("assistant"),
|
||||||
content: Schema.Array(OpenResponsesOutputText),
|
content: Schema.Array(OpenResponsesOutputText),
|
||||||
phase: Schema.optionalKey(MessagePhase),
|
phase: Schema.optionalKey(MessagePhase),
|
||||||
@@ -101,6 +114,7 @@ export const InputItem = Schema.Union([
|
|||||||
OpenResponsesItemReference,
|
OpenResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("function_call"),
|
type: Schema.tag("function_call"),
|
||||||
|
id: Schema.optionalKey(Schema.String),
|
||||||
call_id: Schema.String,
|
call_id: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
arguments: Schema.String,
|
arguments: Schema.String,
|
||||||
@@ -115,6 +129,8 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
|||||||
type LoweredInputItem =
|
type LoweredInputItem =
|
||||||
| OpenResponsesInputItem
|
| OpenResponsesInputItem
|
||||||
| {
|
| {
|
||||||
|
readonly type: "message"
|
||||||
|
readonly id?: string
|
||||||
readonly role: "assistant"
|
readonly role: "assistant"
|
||||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||||
readonly phase?: MessagePhase | null
|
readonly phase?: MessagePhase | null
|
||||||
@@ -128,8 +144,6 @@ type OpenResponsesReasoningInput = {
|
|||||||
summary: Array<{ type: "summary_text"; text: string }>
|
summary: Array<{ type: "summary_text"; text: string }>
|
||||||
encrypted_content?: string | null
|
encrypted_content?: string | null
|
||||||
}
|
}
|
||||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
|
||||||
|
|
||||||
export const Tool = Schema.Struct({
|
export const Tool = Schema.Struct({
|
||||||
type: Schema.tag("function"),
|
type: Schema.tag("function"),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -159,6 +173,14 @@ export const coreFields = {
|
|||||||
tools: optionalArray(Tool),
|
tools: optionalArray(Tool),
|
||||||
tool_choice: Schema.optional(ToolChoice),
|
tool_choice: Schema.optional(ToolChoice),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
|
safety_identifier: Schema.optional(Schema.String),
|
||||||
|
stream_options: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
include_obfuscation: Schema.optional(Schema.Boolean),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||||
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
|
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
|
||||||
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
|
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
|
||||||
prompt_cache_key: Schema.optional(Schema.String),
|
prompt_cache_key: Schema.optional(Schema.String),
|
||||||
@@ -179,6 +201,8 @@ export const coreFields = {
|
|||||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Number),
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Number),
|
||||||
|
presence_penalty: Schema.optional(Schema.Number),
|
||||||
|
frequency_penalty: Schema.optional(Schema.Number),
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenResponsesBody = Schema.Struct({
|
const OpenResponsesBody = Schema.Struct({
|
||||||
@@ -224,6 +248,7 @@ const OpenResponsesErrorPayload = Schema.Struct({
|
|||||||
message: optionalNull(Schema.String),
|
message: optionalNull(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||||
|
|
||||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||||
@@ -296,7 +321,6 @@ export interface Extension {
|
|||||||
readonly media: ProviderShared.NormalizedMedia
|
readonly media: ProviderShared.NormalizedMedia
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
}) => MediaInput | undefined
|
}) => MediaInput | undefined
|
||||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||||
@@ -309,7 +333,6 @@ export interface ParserState {
|
|||||||
readonly hasFunctionCall: boolean
|
readonly hasFunctionCall: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly messageItems: ReadonlySet<string>
|
readonly messageItems: ReadonlySet<string>
|
||||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
|
||||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||||
readonly store: boolean | undefined
|
readonly store: boolean | undefined
|
||||||
@@ -339,7 +362,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
|
|||||||
type: "function" as const,
|
type: "function" as const,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: ToolSchemaProjection.responses(inputSchema),
|
parameters: inputSchema,
|
||||||
// The common tool definition does not currently express Responses strict-schema policy.
|
// The common tool definition does not currently express Responses strict-schema policy.
|
||||||
strict: false,
|
strict: false,
|
||||||
}
|
}
|
||||||
@@ -353,52 +376,63 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
|||||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||||
type: "function_call",
|
const metadata = providerMetadata?.[providerMetadataKey]
|
||||||
call_id: part.id,
|
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||||
name: part.name,
|
? metadata.itemId
|
||||||
arguments: ProviderShared.encodeJson(part.input),
|
: undefined
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||||
|
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||||
|
return {
|
||||||
|
type: "function_call",
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
call_id: part.id,
|
||||||
|
name: part.name,
|
||||||
|
arguments: ProviderShared.encodeJson(part.input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
|
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||||
return undefined
|
if (!ProviderShared.isRecord(metadata) || !id) return undefined
|
||||||
const encryptedContent =
|
const encryptedContent =
|
||||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||||
? metadata.reasoningEncryptedContent
|
? metadata.reasoningEncryptedContent
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: metadata.itemId,
|
id,
|
||||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||||
encrypted_content: encryptedContent,
|
encrypted_content: encryptedContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
|
||||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
|
||||||
? metadata.itemId
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||||
part: MediaPart,
|
part: MediaPart,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
extension: Extension,
|
extension: Extension,
|
||||||
|
target: "message" | "tool-result",
|
||||||
) {
|
) {
|
||||||
const media = ProviderShared.normalizeMedia(part)
|
const media = ProviderShared.normalizeMedia(part)
|
||||||
const extended = extension.lowerMedia?.({ part, media, request })
|
const extended = extension.lowerMedia?.({ part, media, request })
|
||||||
if (extended) return extended
|
if (extended) return extended
|
||||||
|
const url =
|
||||||
|
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||||
|
? part.data
|
||||||
|
: undefined
|
||||||
if (!media.mime.startsWith("image/")) {
|
if (!media.mime.startsWith("image/")) {
|
||||||
|
if (target === "tool-result" && media.mime.startsWith("video/"))
|
||||||
|
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
|
||||||
return {
|
return {
|
||||||
type: "input_file" as const,
|
type: "input_file" as const,
|
||||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||||
file_data: media.dataUrl,
|
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerUserContent = Effect.fnUntraced(function* (
|
const lowerUserContent = Effect.fnUntraced(function* (
|
||||||
@@ -407,10 +441,17 @@ const lowerUserContent = Effect.fnUntraced(function* (
|
|||||||
extension: Extension,
|
extension: Extension,
|
||||||
) {
|
) {
|
||||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||||
if (part.type === "media") return yield* lowerMedia(part, request, extension)
|
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||||
|
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||||
|
if (lowered.type === "input_video")
|
||||||
|
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||||
|
return lowered
|
||||||
|
})
|
||||||
|
|
||||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||||
// content instead of JSON-stringifying base64 into a prompt string.
|
// content instead of JSON-stringifying base64 into a prompt string.
|
||||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||||
@@ -423,6 +464,20 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
|||||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||||
request,
|
request,
|
||||||
extension,
|
extension,
|
||||||
|
"tool-result",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||||
|
item: Content,
|
||||||
|
request: LLMRequest,
|
||||||
|
extension: Extension,
|
||||||
|
) {
|
||||||
|
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||||
|
return yield* lowerMessageMedia(
|
||||||
|
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||||
|
request,
|
||||||
|
extension,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -465,24 +520,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
|
||||||
const reasoningReferences = new Set<string>()
|
const reasoningReferences = new Set<string>()
|
||||||
const hostedToolReferences = new Set<string>()
|
const hostedToolReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (content.length === 0) return
|
if (content.length === 0) return
|
||||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
const groups = content.reduce<
|
||||||
(groups, part) => {
|
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
>((groups, part) => {
|
||||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||||
const group = groups.at(-1)
|
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||||
if (group && group.phase === phase) group.parts.push(part)
|
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||||
else groups.push({ phase, parts: [part] })
|
const group = groups.at(-1)
|
||||||
return groups
|
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||||
},
|
else groups.push({ id, phase, parts: [part] })
|
||||||
[],
|
return groups
|
||||||
)
|
}, [])
|
||||||
input.push(
|
input.push(
|
||||||
...groups.map((group) => ({
|
...groups.map((group) => ({
|
||||||
|
type: "message" as const,
|
||||||
|
...(group.id === undefined ? {} : { id: group.id }),
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||||
@@ -511,34 +568,35 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
existing.encrypted_content = reasoning.encrypted_content
|
existing.encrypted_content = reasoning.encrypted_content
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const replay = {
|
reasoningItems[reasoning.id] = reasoning
|
||||||
type: reasoning.type,
|
input.push(reasoning)
|
||||||
summary: reasoning.summary,
|
|
||||||
encrypted_content: reasoning.encrypted_content,
|
|
||||||
}
|
|
||||||
reasoningItems[reasoning.id] = replay
|
|
||||||
input.push(replay)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
flushText()
|
flushText()
|
||||||
if (part.providerExecuted === true) continue
|
if (part.providerExecuted === true) continue
|
||||||
input.push(lowerToolCall(part))
|
input.push(lowerToolCall(part, providerMetadataKey))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||||
flushText()
|
flushText()
|
||||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||||
input.push({ type: "item_reference", id: itemID })
|
if (store === false) {
|
||||||
if (store === false && part.result.type === "content") {
|
// The server is not storing this exchange, so the tool outcome has to
|
||||||
const content: ReadonlyArray<Content> = part.result.value
|
// travel in the input. Non-content results degrade to their text form.
|
||||||
|
const content: ReadonlyArray<Content> =
|
||||||
|
part.result.type === "content"
|
||||||
|
? part.result.value
|
||||||
|
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||||
input.push({
|
input.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
content: yield* Effect.forEach(content, (item) =>
|
||||||
|
lowerHostedToolResultContentItem(item, request, extension),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (itemID) hostedToolReferences.add(itemID)
|
if (id) hostedToolReferences.add(id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||||
@@ -575,10 +633,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
|||||||
|
|
||||||
const lowerOptions = (request: LLMRequest) => {
|
const lowerOptions = (request: LLMRequest) => {
|
||||||
const options = OpenResponsesOptions.resolve(request)
|
const options = OpenResponsesOptions.resolve(request)
|
||||||
|
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||||
return {
|
return {
|
||||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||||
...(options.store !== undefined ? { store: options.store } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||||
|
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||||
|
...(options.streamOptions?.includeObfuscation !== undefined
|
||||||
|
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
|
||||||
|
: {}),
|
||||||
|
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
|
||||||
|
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||||
...(options.include ? { include: options.include } : {}),
|
...(options.include ? { include: options.include } : {}),
|
||||||
...(options.reasoningEffort || options.reasoningSummary
|
...(options.reasoningEffort || options.reasoningSummary
|
||||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||||
@@ -627,6 +692,8 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
|||||||
max_output_tokens: generation?.maxTokens,
|
max_output_tokens: generation?.maxTokens,
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
|
presence_penalty: generation?.presencePenalty,
|
||||||
|
frequency_penalty: generation?.frequencyPenalty,
|
||||||
...lowerOptions(request),
|
...lowerOptions(request),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -695,7 +762,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
|
|||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const phase = state.messagePhases[id]
|
const phase = state.messagePhases[id]
|
||||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||||
}
|
}
|
||||||
@@ -742,18 +809,17 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
|||||||
// best-effort, not guaranteed.
|
// best-effort, not guaranteed.
|
||||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||||
const item = event.item
|
const item = event.item
|
||||||
if (item?.type === "message" && item.id)
|
if (item?.type === "message" && item.id) {
|
||||||
|
const phase = messagePhase(item.phase)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
messageItems: new Set([...state.messageItems, item.id]),
|
messageItems: new Set([...state.messageItems, item.id]),
|
||||||
messagePhases: (() => {
|
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||||
const phase = state.messagePhase(item.phase)
|
|
||||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
|
||||||
})(),
|
|
||||||
},
|
},
|
||||||
NO_EVENTS,
|
NO_EVENTS,
|
||||||
]
|
]
|
||||||
|
}
|
||||||
if (item && isReasoningItem(item)) {
|
if (item && isReasoningItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
@@ -911,7 +977,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
|
||||||
if (item.type === "message" && item.id) {
|
if (item.type === "message" && item.id) {
|
||||||
const itemPhase = state.messagePhase(item.phase)
|
const itemPhase = messagePhase(item.phase)
|
||||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const messageItems = new Set(state.messageItems)
|
const messageItems = new Set(state.messageItems)
|
||||||
@@ -924,7 +990,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
state.lifecycle,
|
state.lifecycle,
|
||||||
events,
|
events,
|
||||||
item.id,
|
item.id,
|
||||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||||
),
|
),
|
||||||
messageItems,
|
messageItems,
|
||||||
messagePhases,
|
messagePhases,
|
||||||
@@ -937,7 +1003,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
const tools = state.tools[item.id]
|
const tools = state.tools[item.id]
|
||||||
? state.tools
|
? state.tools
|
||||||
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
|
: ToolStream.start(state.tools, item.id, {
|
||||||
|
id: item.call_id,
|
||||||
|
name: item.name,
|
||||||
|
providerMetadata: providerMetadata(state, { itemId: item.id }),
|
||||||
|
})
|
||||||
const result =
|
const result =
|
||||||
item.arguments === undefined
|
item.arguments === undefined
|
||||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||||
@@ -988,11 +1058,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
|||||||
return [state, NO_EVENTS] satisfies StepResult
|
return [state, NO_EVENTS] satisfies StepResult
|
||||||
})
|
})
|
||||||
|
|
||||||
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||||
const events: LLMEvent[] = []
|
// Some compatible providers omit output_item.done even after completing the response.
|
||||||
|
const pending =
|
||||||
|
event.type === "response.completed"
|
||||||
|
? yield* ToolStream.finishAll(state.id, state.tools)
|
||||||
|
: { tools: state.tools, events: NO_EVENTS }
|
||||||
|
const events: LLMEvent[] = [...pending.events]
|
||||||
|
const hasFunctionCall =
|
||||||
|
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasFunctionCall
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason: {
|
reason: {
|
||||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
normalized: mapFinishReason(event, hasFunctionCall),
|
||||||
raw: event.response?.incomplete_details?.reason,
|
raw: event.response?.incomplete_details?.reason,
|
||||||
},
|
},
|
||||||
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
|
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
|
||||||
@@ -1004,35 +1082,48 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
return [{ ...state, lifecycle }, events]
|
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||||
}
|
})
|
||||||
|
|
||||||
// Build a single human-readable message from whatever the provider supplied.
|
// Build the prettiest summary available from whatever the provider supplied.
|
||||||
// When both code and message are present, prefix the code so consumers see
|
// When both code and message are present, prefix the code so consumers see
|
||||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||||
// the bare message — production rate limits and context-length failures used
|
// the bare message — production rate limits and context-length failures used
|
||||||
// to be indistinguishable from generic stream drops.
|
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||||
const providerErrorMessage = (event: Event, fallback: string): string => {
|
// the payload carries no usable summary.
|
||||||
const nested = event.error ?? event.response?.error ?? undefined
|
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||||
const message = event.message || nested?.message || undefined
|
const message = event.message || nested?.message || undefined
|
||||||
const code = event.code || nested?.code || undefined
|
const code = event.code || nested?.code || undefined
|
||||||
if (message && code) return `${code}: ${message}`
|
if (message && code) return `${code}: ${message}`
|
||||||
return message || code || fallback
|
return message || code
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
const nested = event.error ?? event.response?.error ?? undefined
|
||||||
const message = providerErrorMessage(event, fallback)
|
const code = event.code || nested?.code || undefined
|
||||||
|
// Keep the full raw payload on the error even when the message is a summary.
|
||||||
|
const body = JSON.stringify(nested ?? event) ?? ""
|
||||||
|
const summary = providerErrorMessage(event, nested)
|
||||||
|
const message = summary ?? (body === "{}" ? fallback : body)
|
||||||
const status =
|
const status =
|
||||||
typeof event.status === "number"
|
typeof event.status === "number"
|
||||||
? event.status
|
? event.status
|
||||||
: typeof event.status_code === "number"
|
: typeof event.status_code === "number"
|
||||||
? event.status_code
|
? event.status_code
|
||||||
: undefined
|
: undefined
|
||||||
|
const reason =
|
||||||
|
event.type === "error" &&
|
||||||
|
event.error === undefined &&
|
||||||
|
event.response === undefined &&
|
||||||
|
summary === undefined &&
|
||||||
|
status === undefined
|
||||||
|
? new ProviderInternalReason({ message })
|
||||||
|
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||||
return new AIError({
|
return new AIError({
|
||||||
module: id,
|
module: id,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({ message, code, status }),
|
body,
|
||||||
|
reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,14 +1138,20 @@ export const step = (state: ParserState, event: Event) => {
|
|||||||
: onOutputTextDone(state, event, event.item_id),
|
: onOutputTextDone(state, event, event.item_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||||
|
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||||
|
if (!event.item_id || typeof value !== "string")
|
||||||
|
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||||
|
return Effect.succeed(
|
||||||
|
event.type === "response.refusal.delta"
|
||||||
|
? onOutputTextDelta(state, event, event.item_id)
|
||||||
|
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||||
}
|
}
|
||||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
|
||||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
|
||||||
return Effect.succeed(onReasoningDone(state, event))
|
|
||||||
}
|
|
||||||
if (event.type === "response.reasoning_summary_part.added")
|
if (event.type === "response.reasoning_summary_part.added")
|
||||||
return event.item_id
|
return event.item_id
|
||||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||||
@@ -1074,8 +1171,7 @@ export const step = (state: ParserState, event: Event) => {
|
|||||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||||
return onOutputItemDone(state, event)
|
return onOutputItemDone(state, event)
|
||||||
}
|
}
|
||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
|
||||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||||
if (event.type === "error")
|
if (event.type === "error")
|
||||||
return decodeKnownErrorEvent(event).pipe(
|
return decodeKnownErrorEvent(event).pipe(
|
||||||
@@ -1100,17 +1196,11 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
|||||||
tools: ToolStream.empty<string>(),
|
tools: ToolStream.empty<string>(),
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
messageItems: new Set<string>(),
|
messageItems: new Set<string>(),
|
||||||
messagePhase: (value) => messagePhase(value, extension),
|
|
||||||
messagePhases: {},
|
messagePhases: {},
|
||||||
reasoningItems: {},
|
reasoningItems: {},
|
||||||
store: OpenResponsesOptions.resolve(request).store,
|
store: OpenResponsesOptions.resolve(request).store,
|
||||||
})
|
})
|
||||||
|
|
||||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
|
||||||
if (value === "commentary" || value === "final_answer") return value
|
|
||||||
return extension.messagePhase?.(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const protocol = Protocol.make({
|
export const protocol = Protocol.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
|||||||
import { ToolStream } from "./utils/tool-stream.js"
|
import { ToolStream } from "./utils/tool-stream.js"
|
||||||
|
|
||||||
const ADAPTER = "openai-chat"
|
const ADAPTER = "openai-chat"
|
||||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/chat/completions"
|
export const PATH = "/chat/completions"
|
||||||
|
|
||||||
@@ -156,6 +156,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
|||||||
prompt_tokens: optionalNull(Schema.Number),
|
prompt_tokens: optionalNull(Schema.Number),
|
||||||
completion_tokens: optionalNull(Schema.Number),
|
completion_tokens: optionalNull(Schema.Number),
|
||||||
total_tokens: optionalNull(Schema.Number),
|
total_tokens: optionalNull(Schema.Number),
|
||||||
|
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||||
|
cached_tokens: optionalNull(Schema.Number),
|
||||||
|
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||||
prompt_tokens_details: optionalNull(
|
prompt_tokens_details: optionalNull(
|
||||||
Schema.StructWithRest(
|
Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
@@ -194,6 +197,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
|||||||
const OpenAIChatDelta = Schema.StructWithRest(
|
const OpenAIChatDelta = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
|
refusal: optionalNull(Schema.String),
|
||||||
reasoning_content: optionalNull(Schema.String),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
reasoning: optionalNull(Schema.String),
|
reasoning: optionalNull(Schema.String),
|
||||||
reasoning_text: optionalNull(Schema.String),
|
reasoning_text: optionalNull(Schema.String),
|
||||||
@@ -203,11 +207,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
|||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
)
|
)
|
||||||
|
|
||||||
const OpenAIChatChoice = Schema.Struct({
|
const OpenAIChatChoice = Schema.StructWithRest(
|
||||||
delta: optionalNull(OpenAIChatDelta),
|
Schema.Struct({
|
||||||
finish_reason: optionalNull(Schema.String),
|
delta: optionalNull(OpenAIChatDelta),
|
||||||
native_finish_reason: optionalNull(Schema.String),
|
finish_reason: optionalNull(Schema.String),
|
||||||
})
|
native_finish_reason: optionalNull(Schema.String),
|
||||||
|
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||||
|
usage: optionalNull(OpenAIChatUsage),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
const OpenAIChatError = Schema.Struct({
|
const OpenAIChatError = Schema.Struct({
|
||||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||||
@@ -260,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
|
|||||||
function: {
|
function: {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
parameters: inputSchema,
|
||||||
},
|
},
|
||||||
cache_control: options.cacheControl?.(tool.cache),
|
cache_control: options.cacheControl?.(tool.cache),
|
||||||
})
|
})
|
||||||
@@ -508,11 +517,23 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||||
|
// whenever the conversation history contains tool calls/results. Send an
|
||||||
|
// explicit empty array when we have history but no active tools.
|
||||||
|
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "tool") return true
|
||||||
|
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const lowerOptions = (request: LLMRequest) => {
|
const lowerOptions = (request: LLMRequest) => {
|
||||||
const options = OpenAIOptions.resolve(request)
|
const options = OpenAIOptions.resolve(request)
|
||||||
|
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||||
return {
|
return {
|
||||||
...(options.store !== undefined ? { store: options.store } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,12 +552,15 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
|||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||||
|
const hasHistory = hasToolHistory(request.messages)
|
||||||
return {
|
return {
|
||||||
model: request.model.id,
|
model: request.model.id,
|
||||||
messages: yield* lowerMessages(request, options),
|
messages: yield* lowerMessages(request, options),
|
||||||
tools:
|
tools:
|
||||||
request.tools.length === 0
|
request.tools.length === 0
|
||||||
? undefined
|
? hasHistory
|
||||||
|
? []
|
||||||
|
: undefined
|
||||||
: request.tools.map((tool) =>
|
: request.tools.map((tool) =>
|
||||||
lowerTool(
|
lowerTool(
|
||||||
tool,
|
tool,
|
||||||
@@ -580,11 +604,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||||
// satisfied on both sides.
|
// satisfied on both sides.
|
||||||
|
// Providers differ on cache-hit location: OpenAI uses
|
||||||
|
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||||
|
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
const input = usage.prompt_tokens ?? undefined
|
const input = usage.prompt_tokens ?? undefined
|
||||||
const output = usage.completion_tokens ?? undefined
|
const output = usage.completion_tokens ?? undefined
|
||||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
const cached =
|
||||||
|
(usage.prompt_tokens_details?.cached_tokens ??
|
||||||
|
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||||
|
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||||
|
undefined) as number | undefined
|
||||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||||
@@ -690,8 +721,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const usage = mapUsage(event.usage) ?? state.usage
|
|
||||||
const choice = event.choices?.[0]
|
const choice = event.choices?.[0]
|
||||||
|
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||||
|
// `choice.usage` instead of the top-level `usage` field.
|
||||||
|
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||||
|
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||||
const rawFinishReason = choice?.finish_reason
|
const rawFinishReason = choice?.finish_reason
|
||||||
const finishReason =
|
const finishReason =
|
||||||
rawFinishReason !== undefined && rawFinishReason !== null
|
rawFinishReason !== undefined && rawFinishReason !== null
|
||||||
@@ -709,6 +743,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||||
const hasLateContent =
|
const hasLateContent =
|
||||||
Boolean(delta?.content) ||
|
Boolean(delta?.content) ||
|
||||||
|
Boolean(delta?.refusal) ||
|
||||||
reasoning !== undefined ||
|
reasoning !== undefined ||
|
||||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||||
@@ -728,7 +763,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
else if (
|
else if (
|
||||||
reasoningDetailsObserved &&
|
reasoningDetailsObserved &&
|
||||||
!lifecycle.reasoning.has("reasoning-0") &&
|
!lifecycle.reasoning.has("reasoning-0") &&
|
||||||
(Boolean(delta?.content) || toolDeltas.length > 0)
|
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
|
||||||
)
|
)
|
||||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||||
@@ -743,6 +778,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (delta?.refusal) {
|
||||||
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"reasoning-0",
|
||||||
|
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||||
|
)
|
||||||
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||||
|
}
|
||||||
|
|
||||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||||
for (const [position, tool] of toolDeltas.entries()) {
|
for (const [position, tool] of toolDeltas.entries()) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
|
|||||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||||
return Effect.succeed(undefined)
|
return Effect.undefined
|
||||||
})
|
})
|
||||||
const multipartMask =
|
const multipartMask =
|
||||||
mask === undefined
|
mask === undefined
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
|
|||||||
import { Endpoint } from "../route/endpoint.js"
|
import { Endpoint } from "../route/endpoint.js"
|
||||||
import { Protocol } from "../route/protocol.js"
|
import { Protocol } from "../route/protocol.js"
|
||||||
import { HttpTransport } from "../route/transport/index.js"
|
import { HttpTransport } from "../route/transport/index.js"
|
||||||
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||||
import { OpenResponses } from "./open-responses.js"
|
import { OpenResponses } from "./open-responses.js"
|
||||||
import { optionalArray, ProviderShared } from "./shared.js"
|
import { optionalArray, ProviderShared } from "./shared.js"
|
||||||
import { Lifecycle } from "./utils/lifecycle.js"
|
|
||||||
import { OpenAIImage } from "./utils/openai-image.js"
|
import { OpenAIImage } from "./utils/openai-image.js"
|
||||||
|
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
|
||||||
|
|
||||||
const ADAPTER = "openai-responses"
|
const ADAPTER = "openai-responses"
|
||||||
const NAME = "OpenAI Responses"
|
const NAME = "OpenAI Responses"
|
||||||
@@ -40,18 +39,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
|||||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const OpenAIResponsesInputItem = Schema.Union([
|
|
||||||
Schema.Struct({
|
|
||||||
role: Schema.tag("assistant"),
|
|
||||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
|
||||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
|
||||||
}),
|
|
||||||
OpenResponses.InputItem,
|
|
||||||
])
|
|
||||||
|
|
||||||
const OpenAIResponsesCoreFields = {
|
const OpenAIResponsesCoreFields = {
|
||||||
...OpenResponses.coreFields,
|
...OpenResponses.coreFields,
|
||||||
input: Schema.Array(OpenAIResponsesInputItem),
|
|
||||||
tools: optionalArray(OpenAIResponsesTools),
|
tools: optionalArray(OpenAIResponsesTools),
|
||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
}
|
}
|
||||||
@@ -65,16 +54,6 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
|||||||
const extension = {
|
const extension = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
name: NAME,
|
name: NAME,
|
||||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
|
||||||
lowerMedia: ({ part, media, request }) => {
|
|
||||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
|
||||||
return {
|
|
||||||
type: "input_file",
|
|
||||||
filename: part.filename ?? "document.pdf",
|
|
||||||
file_data: media.base64,
|
|
||||||
mime_type: media.mime,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} satisfies OpenResponses.Extension
|
} satisfies OpenResponses.Extension
|
||||||
|
|
||||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||||
@@ -126,46 +105,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
|||||||
} satisfies OpenAIResponsesBody
|
} satisfies OpenAIResponsesBody
|
||||||
})
|
})
|
||||||
|
|
||||||
type HostedToolData = OpenResponses.StreamItem & {
|
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||||
readonly id: string
|
|
||||||
readonly status?: string
|
|
||||||
readonly action?: unknown
|
|
||||||
readonly queries?: unknown
|
|
||||||
readonly results?: unknown
|
|
||||||
readonly code?: string
|
|
||||||
readonly container_id?: string
|
|
||||||
readonly outputs?: unknown
|
|
||||||
readonly server_label?: string
|
|
||||||
readonly output?: unknown
|
|
||||||
readonly result?: string
|
|
||||||
readonly output_format?: "png" | "jpeg" | "webp"
|
|
||||||
readonly error?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOSTED_TOOLS = {
|
|
||||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
|
||||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
|
||||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
|
||||||
code_interpreter_call: {
|
|
||||||
name: "code_interpreter",
|
|
||||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
|
||||||
},
|
|
||||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
|
||||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
|
||||||
mcp_call: {
|
|
||||||
name: "mcp",
|
|
||||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
|
||||||
},
|
|
||||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
|
||||||
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
|
|
||||||
|
|
||||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
|
||||||
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
|
||||||
|
|
||||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
|
||||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
|
||||||
|
|
||||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
|
||||||
const isError = item.error !== undefined && item.error !== null
|
const isError = item.error !== undefined && item.error !== null
|
||||||
if (item.type === "image_generation_call" && item.result) {
|
if (item.type === "image_generation_call" && item.result) {
|
||||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||||
@@ -186,32 +126,21 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
|||||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||||
})
|
})
|
||||||
|
|
||||||
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
|
const HOSTED_TOOLS = {
|
||||||
state: OpenResponses.ParserState,
|
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||||
item: HostedToolItem,
|
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||||
) {
|
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||||
const tool = HOSTED_TOOLS[item.type]
|
code_interpreter_call: {
|
||||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
name: "code_interpreter",
|
||||||
const events: LLMEvent[] = []
|
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
},
|
||||||
events.push(
|
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||||
LLMEvent.toolCall({
|
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||||
id: item.id,
|
mcp_call: {
|
||||||
name: tool.name,
|
name: "mcp",
|
||||||
input: tool.input(item),
|
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||||
providerExecuted: true,
|
},
|
||||||
providerMetadata,
|
} as const satisfies ResponsesHostedTools.Definitions
|
||||||
}),
|
|
||||||
LLMEvent.toolResult({
|
|
||||||
id: item.id,
|
|
||||||
name: tool.name,
|
|
||||||
result: yield* hostedToolResult(item),
|
|
||||||
providerExecuted: true,
|
|
||||||
providerMetadata,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
|
||||||
})
|
|
||||||
|
|
||||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||||
@@ -222,8 +151,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
|||||||
return event.item_id
|
return event.item_id
|
||||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||||
return onHostedToolDone(state, event.item)
|
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||||
return OpenResponses.step(state, event)
|
return OpenResponses.step(state, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,12 +174,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
|||||||
const auth = Auth.none
|
const auth = Auth.none
|
||||||
|
|
||||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||||
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
|
||||||
|
export const transport = channelTransport({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
name: NAME,
|
name: NAME,
|
||||||
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
||||||
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
||||||
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
|||||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||||
|
|
||||||
|
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||||
|
|
||||||
|
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||||
|
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||||
|
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
|
||||||
|
if (key === undefined) return undefined
|
||||||
|
const chars = Array.from(key)
|
||||||
|
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
|
||||||
|
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming tool-call accumulator. Adapters that build a tool call across
|
* Streaming tool-call accumulator. Adapters that build a tool call across
|
||||||
* multiple `tool-input-delta` chunks store the partial JSON input string here
|
* multiple `tool-input-delta` chunks store the partial JSON input string here
|
||||||
@@ -197,19 +208,28 @@ export const errorText = (error: unknown) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||||
* decoder, and drops empty / `[DONE]` keep-alive events so the protocol event
|
* decoder, optionally filters named events, and drops empty / `[DONE]`
|
||||||
* schema sees one JSON string per element. The SSE channel emits a
|
* keep-alive events so the protocol event schema sees one JSON string per
|
||||||
|
* element. The SSE channel emits a
|
||||||
* `Retry` control event on its error channel; we drop it here (we don't
|
* `Retry` control event on its error channel; we drop it here (we don't
|
||||||
* implement client-driven retries). Decoder failures become provider output
|
* implement client-driven retries). Decoder failures become provider output
|
||||||
* errors so the public error channel stays `AIError`.
|
* errors so the public error channel stays `AIError`.
|
||||||
*/
|
*/
|
||||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
export const sseFraming = (
|
||||||
|
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||||
|
events?: ReadonlySet<string>,
|
||||||
|
): Stream.Stream<string, AIError> =>
|
||||||
bytes.pipe(
|
bytes.pipe(
|
||||||
Stream.decodeText(),
|
Stream.decodeText(),
|
||||||
Stream.pipeThroughChannel(Sse.decode()),
|
Stream.pipeThroughChannel(Sse.decode()),
|
||||||
Stream.catchTag("Retry", () => Stream.empty),
|
Stream.catchTag("Retry", () => Stream.empty),
|
||||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
Stream.filter(
|
||||||
|
(event) =>
|
||||||
|
(events === undefined || events.has(event.event)) &&
|
||||||
|
event.data.length > 0 &&
|
||||||
|
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
|
||||||
|
),
|
||||||
Stream.map((event) => event.data),
|
Stream.map((event) => event.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
|
|||||||
url: string,
|
url: string,
|
||||||
module: string,
|
module: string,
|
||||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||||
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
if (!url.startsWith("data:")) return Effect.undefined
|
||||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
|
|||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const textDelta = (
|
||||||
|
state: State,
|
||||||
|
events: LLMEvent[],
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
providerMetadata?: ProviderMetadata,
|
||||||
|
): State => {
|
||||||
const started = textStart(state, events, id)
|
const started = textStart(state, events, id)
|
||||||
events.push(LLMEvent.textDelta({ id, text }))
|
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
|
|||||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||||
|
|
||||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||||
|
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||||
|
(value): value is ServiceTier => typeof value === "string",
|
||||||
|
{ title: "ServiceTier" },
|
||||||
|
)
|
||||||
|
|
||||||
export const Truncations = ["auto", "disabled"] as const
|
export const Truncations = ["auto", "disabled"] as const
|
||||||
export type Truncation = (typeof Truncations)[number]
|
export type Truncation = (typeof Truncations)[number]
|
||||||
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
|||||||
(value): value is ResponseIncludable => typeof value === "string",
|
(value): value is ResponseIncludable => typeof value === "string",
|
||||||
{ title: "ResponseIncludable" },
|
{ title: "ResponseIncludable" },
|
||||||
)
|
)
|
||||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
export const ServiceTierSchema = ServiceTier
|
||||||
export const TruncationSchema = Schema.Literals(Truncations)
|
export const TruncationSchema = Schema.Literals(Truncations)
|
||||||
|
|
||||||
export const AllowedTools = Schema.Struct({
|
export const AllowedTools = Schema.Struct({
|
||||||
@@ -47,9 +51,17 @@ export const AllowedTools = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type AllowedTools = typeof AllowedTools.Type
|
export type AllowedTools = typeof AllowedTools.Type
|
||||||
|
|
||||||
|
export const StreamOptions = Schema.Struct({
|
||||||
|
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||||
|
})
|
||||||
|
|
||||||
export const Options = Schema.Struct({
|
export const Options = Schema.Struct({
|
||||||
instructions: Schema.optional(Schema.String),
|
instructions: Schema.optional(Schema.String),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
|
safetyIdentifier: Schema.optional(Schema.String),
|
||||||
|
streamOptions: Schema.optional(StreamOptions),
|
||||||
|
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
|||||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||||
|
|
||||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
|
||||||
|
import { OpenResponses } from "../open-responses.js"
|
||||||
|
import { Lifecycle } from "./lifecycle.js"
|
||||||
|
|
||||||
|
export type Item = OpenResponses.StreamItem & {
|
||||||
|
readonly id: string
|
||||||
|
readonly status?: string
|
||||||
|
readonly action?: unknown
|
||||||
|
readonly queries?: unknown
|
||||||
|
readonly results?: unknown
|
||||||
|
readonly code?: string
|
||||||
|
readonly container_id?: string
|
||||||
|
readonly outputs?: unknown
|
||||||
|
readonly server_label?: string
|
||||||
|
readonly output?: unknown
|
||||||
|
readonly result?: string
|
||||||
|
readonly output_format?: "png" | "jpeg" | "webp"
|
||||||
|
readonly error?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Definition {
|
||||||
|
readonly name: string
|
||||||
|
readonly input: (item: Item) => unknown
|
||||||
|
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Definitions = Readonly<Record<string, Definition>>
|
||||||
|
|
||||||
|
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
|
||||||
|
item.type in tools && typeof item.id === "string" && item.id.length > 0
|
||||||
|
|
||||||
|
export const onDone: (
|
||||||
|
state: OpenResponses.ParserState,
|
||||||
|
item: Item,
|
||||||
|
tools: Definitions,
|
||||||
|
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
|
||||||
|
state,
|
||||||
|
item,
|
||||||
|
tools,
|
||||||
|
) {
|
||||||
|
const tool = tools[item.type]
|
||||||
|
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||||
|
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
|
events.push(
|
||||||
|
LLMEvent.toolCall({
|
||||||
|
id: item.id,
|
||||||
|
name: tool.name,
|
||||||
|
input: tool.input(item),
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata,
|
||||||
|
}),
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: item.id,
|
||||||
|
name: tool.name,
|
||||||
|
result: tool.result
|
||||||
|
? yield* tool.result(item)
|
||||||
|
: item.error !== undefined && item.error !== null
|
||||||
|
? { type: "error", value: item.error }
|
||||||
|
: { type: "json", value: item },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
|
||||||
@@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
|
|||||||
import { isRecord } from "../../utils/record.js"
|
import { isRecord } from "../../utils/record.js"
|
||||||
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
||||||
|
|
||||||
const removeNullSchemas = (value: unknown): unknown => {
|
|
||||||
if (Array.isArray(value)) return value.map(removeNullSchemas)
|
|
||||||
if (!isRecord(value)) return value
|
|
||||||
const fields = Object.fromEntries(
|
|
||||||
Object.entries(value)
|
|
||||||
.filter(([key]) => key !== "anyOf")
|
|
||||||
.map(([key, field]) => [key, removeNullSchemas(field)]),
|
|
||||||
)
|
|
||||||
if (!Array.isArray(value.anyOf)) return fields
|
|
||||||
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
|
|
||||||
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
|
|
||||||
return { ...fields, anyOf: variants }
|
|
||||||
}
|
|
||||||
|
|
||||||
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
||||||
const projected = items.map(moonshotNode)
|
const projected = items.map(moonshotNode)
|
||||||
if (projected.length === 0) return {}
|
if (projected.length === 0) return {}
|
||||||
@@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
|
|||||||
return isRecord(projected) ? projected : {}
|
return isRecord(projected) ? projected : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAI = (schema: JsonSchema): JsonSchema => {
|
const openAI = (schema: JsonSchema): JsonSchema => schema
|
||||||
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
|
|
||||||
const flattened =
|
|
||||||
variants.length === 0
|
|
||||||
? { ...schema, type: "object" }
|
|
||||||
: {
|
|
||||||
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
|
|
||||||
type: "object",
|
|
||||||
properties: variants.reduce(
|
|
||||||
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
additionalProperties: false,
|
|
||||||
}
|
|
||||||
const normalized = removeNullSchemas(flattened)
|
|
||||||
return isRecord(normalized) ? normalized : { type: "object" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const responses = openAI
|
const responses = openAI
|
||||||
|
|
||||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { Protocol } from "../route/protocol.js"
|
||||||
|
import { OpenResponses } from "./open-responses.js"
|
||||||
|
import { ProviderShared } from "./shared.js"
|
||||||
|
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||||
|
|
||||||
|
const ADAPTER = "xai-responses"
|
||||||
|
const NAME = "xAI Responses"
|
||||||
|
|
||||||
|
const extension = {
|
||||||
|
id: ADAPTER,
|
||||||
|
name: NAME,
|
||||||
|
} satisfies OpenResponses.Extension
|
||||||
|
|
||||||
|
const HOSTED_TOOLS = {
|
||||||
|
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||||
|
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
|
||||||
|
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||||
|
code_interpreter_call: {
|
||||||
|
name: "code_interpreter",
|
||||||
|
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||||
|
},
|
||||||
|
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||||
|
mcp_call: {
|
||||||
|
name: "mcp",
|
||||||
|
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||||
|
},
|
||||||
|
} as const satisfies ResponsesHostedTools.Definitions
|
||||||
|
|
||||||
|
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||||
|
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||||
|
return event.item_id
|
||||||
|
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||||
|
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||||
|
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||||
|
return event.item_id
|
||||||
|
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||||
|
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||||
|
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||||
|
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||||
|
return OpenResponses.step(state, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const protocol = Protocol.make({
|
||||||
|
id: ADAPTER,
|
||||||
|
body: OpenResponses.protocol.body,
|
||||||
|
stream: {
|
||||||
|
event: OpenResponses.protocol.stream.event,
|
||||||
|
initial: (request) => OpenResponses.initial(request, extension),
|
||||||
|
step,
|
||||||
|
terminal: OpenResponses.terminal,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as XAIResponses from "./xai-responses.js"
|
||||||
@@ -74,11 +74,15 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
|||||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||||
|
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||||
|
|
||||||
export interface ProviderFailure {
|
export interface ProviderFailure {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly status?: number | undefined
|
readonly status?: number | undefined
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
|
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||||
|
// that the summary message does not carry. Not shown to users.
|
||||||
|
readonly rawBody?: string | undefined
|
||||||
readonly retryAfterMs?: number | undefined
|
readonly retryAfterMs?: number | undefined
|
||||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||||
readonly http?: HttpContext | undefined
|
readonly http?: HttpContext | undefined
|
||||||
@@ -88,11 +92,13 @@ export interface ProviderFailure {
|
|||||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||||
// session retry policy never needs provider-specific string matching.
|
// session retry policy never needs provider-specific string matching.
|
||||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||||
const body = input.http?.body ?? ""
|
const body = input.http?.body ?? input.rawBody ?? ""
|
||||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||||
.filter((code): code is string => code !== undefined)
|
.filter((code): code is string => code !== undefined)
|
||||||
.map((code) => code.toLowerCase())
|
.map((code) => code.toLowerCase())
|
||||||
const text = body || input.message
|
// Scan the raw payload too so signals missing from the summary message
|
||||||
|
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||||
|
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
|||||||
retryAfterMs: input.retryAfterMs,
|
retryAfterMs: input.retryAfterMs,
|
||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
|
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
|
||||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||||
return new ProviderInternalReason({
|
return new ProviderInternalReason({
|
||||||
...common,
|
...common,
|
||||||
|
|||||||
@@ -1,62 +1,16 @@
|
|||||||
import { Auth } from "./route/auth.js"
|
|
||||||
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
|
|
||||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||||
|
|
||||||
export interface Settings {}
|
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||||
|
readonly baseURL?: string
|
||||||
export type Credential =
|
|
||||||
| {
|
|
||||||
readonly type: "key"
|
|
||||||
readonly value: string
|
|
||||||
readonly configuration?: Readonly<Record<string, unknown>>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly type: "oauth"
|
|
||||||
readonly accessToken: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Defaults {
|
|
||||||
readonly headers?: Readonly<Record<string, string>>
|
readonly headers?: Readonly<Record<string, string>>
|
||||||
readonly body?: Readonly<Record<string, unknown>>
|
readonly body?: Readonly<Record<string, unknown>>
|
||||||
readonly limits?: {
|
|
||||||
readonly context: number
|
|
||||||
readonly input?: number
|
|
||||||
readonly output: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInput<ProviderSettings extends Settings = Settings> {
|
|
||||||
readonly id: string
|
|
||||||
readonly settings: ProviderSettings
|
|
||||||
readonly credential?: Credential
|
|
||||||
readonly defaults: Defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routeDefaults = (input: Defaults) => ({
|
|
||||||
headers: input.headers,
|
|
||||||
http: input.body === undefined ? undefined : { body: input.body },
|
|
||||||
limits: input.limits,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
|
|
||||||
|
|
||||||
export const bearerAuthOption = (input: Credential): AuthOverride => ({
|
|
||||||
auth: Auth.bearer(bearerCredentialValue(input)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const apiKeyOrBearerAuthOption = (
|
|
||||||
input: Credential,
|
|
||||||
competingKeyHeader: string,
|
|
||||||
): RequiredApiKeyAuth | AuthOverride =>
|
|
||||||
input.type === "key"
|
|
||||||
? { apiKey: input.value }
|
|
||||||
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
|
|
||||||
|
|
||||||
export interface Definition<
|
export interface Definition<
|
||||||
ProviderSettings extends Settings = Settings,
|
ProviderSettings extends Settings = Settings,
|
||||||
Options extends ProviderOptions = ProviderOptions,
|
Options extends ProviderOptions = ProviderOptions,
|
||||||
> {
|
> {
|
||||||
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
|
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as ProviderPackage from "./provider-package.js"
|
export * as ProviderPackage from "./provider-package.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||||
@@ -79,27 +79,28 @@ export const configure = (input: Config = {}) => {
|
|||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
|
|
||||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
const config = (settings: Settings): Config => {
|
||||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||||
return {
|
return {
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||||
apiKey: input.credential
|
baseURL: settings.baseURL,
|
||||||
? ProviderPackage.bearerCredentialValue(input.credential)
|
credentials: settings.credentials,
|
||||||
: input.settings.auth === "sigv4"
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
? undefined
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
: input.settings.apiKey,
|
providerOptions: settings.providerOptions,
|
||||||
baseURL: input.settings.baseURL,
|
region: settings.region,
|
||||||
credentials: input.settings.credentials,
|
|
||||||
providerOptions: input.settings.providerOptions,
|
|
||||||
region: input.settings.region,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
configure(config(input)).chat(input.id)
|
modelID,
|
||||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
settings,
|
||||||
configure(config(input)).responses(input.id)
|
) => configure(config(settings)).chat(modelID)
|
||||||
|
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).responses(modelID)
|
||||||
export const model = chatModel
|
export const model = chatModel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||||
@@ -50,21 +50,18 @@ export const configure = (input: Config = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||||
apiKey: input.credential
|
baseURL: settings.baseURL,
|
||||||
? ProviderPackage.bearerCredentialValue(input.credential)
|
credentials: settings.credentials,
|
||||||
: input.settings.auth === "sigv4"
|
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||||
? undefined
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
: input.settings.apiKey,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
baseURL: input.settings.baseURL,
|
region: settings.region,
|
||||||
credentials: input.settings.credentials,
|
}).model(modelID)
|
||||||
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
|
|
||||||
region: input.settings.region,
|
|
||||||
}).model(input.id)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||||
@@ -32,9 +32,7 @@ export const routes = [AnthropicMessages.route]
|
|||||||
|
|
||||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||||
if ("auth" in input && input.auth) return input.auth
|
if ("auth" in input && input.auth) return input.auth
|
||||||
return Auth.remove("authorization").andThen(
|
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||||
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configure = (input: Config) => {
|
export const configure = (input: Config) => {
|
||||||
@@ -59,20 +57,20 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||||
...(input.credential
|
baseURL: settings.baseURL,
|
||||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
: input.settings.authToken === undefined
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
? { apiKey: input.settings.apiKey }
|
provider: settings.provider,
|
||||||
: { auth: Auth.bearer(input.settings.authToken) }),
|
providerOptions: settings.providerOptions,
|
||||||
baseURL: input.settings.baseURL,
|
}).model(modelID)
|
||||||
provider: input.settings.provider,
|
|
||||||
providerOptions: input.settings.providerOptions,
|
|
||||||
}).model(input.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as AnthropicCompatible from "./anthropic-compatible.js"
|
export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||||
@@ -31,11 +31,9 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||||
if ("auth" in options && options.auth) return options.auth
|
if ("auth" in options && options.auth) return options.auth
|
||||||
return Auth.remove("authorization").andThen(
|
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||||
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
||||||
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
.pipe(Auth.header("x-api-key"))
|
||||||
.pipe(Auth.header("x-api-key")),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
@@ -54,17 +52,17 @@ export const configure = (input: Config = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||||
...(input.credential
|
baseURL: settings.baseURL,
|
||||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
: input.settings.authToken === undefined
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
? { apiKey: input.settings.apiKey }
|
providerOptions: settings.providerOptions,
|
||||||
: { auth: Auth.bearer(input.settings.authToken) }),
|
}).model(modelID)
|
||||||
baseURL: input.settings.baseURL,
|
|
||||||
providerOptions: input.settings.providerOptions,
|
|
||||||
}).model(input.id)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||||
@@ -10,6 +11,7 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
|||||||
|
|
||||||
export const id = ProviderID.make("azure")
|
export const id = ProviderID.make("azure")
|
||||||
const routeAuth = Auth.remove("authorization")
|
const routeAuth = Auth.remove("authorization")
|
||||||
|
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||||
|
|
||||||
// Azure needs the customer's resource URL; supply either `resourceName`
|
// Azure needs the customer's resource URL; supply either `resourceName`
|
||||||
// (helper builds the URL) or `baseURL` directly.
|
// (helper builds the URL) or `baseURL` directly.
|
||||||
@@ -40,6 +42,30 @@ const responsesRoute = OpenAIResponses.route.with({
|
|||||||
id: "azure-openai-responses",
|
id: "azure-openai-responses",
|
||||||
provider: id,
|
provider: id,
|
||||||
auth: routeAuth,
|
auth: routeAuth,
|
||||||
|
transport: OpenAIResponses.channelTransport({
|
||||||
|
id: "azure-openai-responses",
|
||||||
|
name: "Azure OpenAI Responses",
|
||||||
|
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||||
|
enabled: (value) => {
|
||||||
|
const url = new URL(value)
|
||||||
|
return (
|
||||||
|
url.protocol === "https:" &&
|
||||||
|
url.hostname.endsWith(".openai.azure.com") &&
|
||||||
|
url.pathname.endsWith("/openai/v1/responses") &&
|
||||||
|
url.searchParams.get("api-version") === "v1"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
url: (value) => {
|
||||||
|
const url = new URL(value)
|
||||||
|
url.searchParams.delete("api-version")
|
||||||
|
return url.toString()
|
||||||
|
},
|
||||||
|
headers: (headers) => {
|
||||||
|
const apiKey = headers["api-key"]
|
||||||
|
if (!apiKey) return headers
|
||||||
|
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatRoute = OpenAIChat.route.with({
|
const chatRoute = OpenAIChat.route.with({
|
||||||
@@ -120,29 +146,27 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
const config = (settings: Settings): Config => {
|
||||||
const settings = input.settings
|
|
||||||
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
|
|
||||||
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
|
|
||||||
const resourceName =
|
|
||||||
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
|
|
||||||
const common = {
|
const common = {
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential
|
|
||||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
|
|
||||||
: { apiKey: settings.apiKey }),
|
|
||||||
apiVersion: settings.apiVersion,
|
apiVersion: settings.apiVersion,
|
||||||
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
providerOptions: settings.providerOptions,
|
providerOptions: settings.providerOptions,
|
||||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||||
}
|
}
|
||||||
if (baseURL !== undefined) return { ...common, baseURL }
|
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||||
if (resourceName !== undefined) return { ...common, resourceName }
|
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||||
throw new Error("Azure requires resourceName or baseURL")
|
throw new Error("Azure requires resourceName or baseURL")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
configure(config(input)).responses(input.id)
|
modelID,
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
settings,
|
||||||
configure(config(input)).chat(input.id)
|
) => configure(config(settings)).responses(modelID)
|
||||||
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const model = responsesModel
|
export const model = responsesModel
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
@@ -68,15 +68,15 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||||
throw new Error("Google Vertex Chat does not support API keys")
|
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
accessToken: settings.accessToken,
|
||||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
location: input.settings.location,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
project: input.settings.project,
|
location: settings.location,
|
||||||
providerOptions: input.settings.providerOptions,
|
project: settings.project,
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Effect, Schema, Struct } from "effect"
|
import { Effect, Schema, Struct } from "effect"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||||
import { Endpoint } from "../route/endpoint.js"
|
import { Endpoint } from "../route/endpoint.js"
|
||||||
import { Framing } from "../route/framing.js"
|
|
||||||
import { Protocol } from "../route/protocol.js"
|
import { Protocol } from "../route/protocol.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||||
@@ -14,6 +13,7 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
|
|||||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||||
|
|
||||||
const VERSION = "vertex-2023-10-16" as const
|
const VERSION = "vertex-2023-10-16" as const
|
||||||
|
const HEADER_VERSION = "2023-06-01" as const
|
||||||
|
|
||||||
export const id = ProviderID.make("google-vertex")
|
export const id = ProviderID.make("google-vertex")
|
||||||
|
|
||||||
@@ -57,7 +57,8 @@ const route = Route.make({
|
|||||||
}),
|
}),
|
||||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||||
auth: Auth.none,
|
auth: Auth.none,
|
||||||
framing: Framing.sse,
|
framing: AnthropicMessages.framing,
|
||||||
|
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const routes = [route]
|
export const routes = [route]
|
||||||
@@ -100,15 +101,18 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
modelID,
|
||||||
throw new Error("Google Vertex Messages does not support API keys")
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
accessToken: settings.accessToken,
|
||||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
location: input.settings.location,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
project: input.settings.project,
|
location: settings.location,
|
||||||
providerOptions: input.settings.providerOptions,
|
project: settings.project,
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||||
@@ -70,15 +70,18 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
modelID,
|
||||||
throw new Error("Google Vertex Responses does not support API keys")
|
settings,
|
||||||
|
) => {
|
||||||
|
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
accessToken: settings.accessToken,
|
||||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
location: input.settings.location,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
project: input.settings.project,
|
location: settings.location,
|
||||||
providerOptions: input.settings.providerOptions,
|
project: settings.project,
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ const adc = (project?: string) => {
|
|||||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||||
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
|
if (input.auth) return input.auth
|
||||||
return Auth.remove("x-goog-api-key").andThen(auth)
|
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||||
|
return adc(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as GoogleVertexShared from "./google-vertex-shared.js"
|
export * as GoogleVertexShared from "./google-vertex-shared.js"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { Gemini } from "../protocols/gemini.js"
|
import { Gemini } from "../protocols/gemini.js"
|
||||||
import { ProviderShared } from "../protocols/shared.js"
|
import { ProviderShared } from "../protocols/shared.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
|
|
||||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||||
const body = yield* Gemini.protocol.body.from(request)
|
const body = yield* Gemini.protocol.body.from(request)
|
||||||
|
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||||
|
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||||
|
const contents = body.contents.map((content) => ({
|
||||||
|
...content,
|
||||||
|
parts: (content.parts ?? []).map((part) => {
|
||||||
|
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||||
|
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||||
|
return part
|
||||||
|
}),
|
||||||
|
}))
|
||||||
const value = request.providerOptions?.labels
|
const value = request.providerOptions?.labels
|
||||||
const labels = ProviderShared.isRecord(value)
|
const labels = ProviderShared.isRecord(value)
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
return { ...body, labels }
|
return { ...body, contents, labels }
|
||||||
})
|
})
|
||||||
|
|
||||||
const protocol = {
|
const protocol = {
|
||||||
@@ -94,10 +104,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
|||||||
return route.with({
|
return route.with({
|
||||||
...rest,
|
...rest,
|
||||||
endpoint: { baseURL: endpoint },
|
endpoint: { baseURL: endpoint },
|
||||||
auth:
|
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||||
apiKey === undefined
|
|
||||||
? GoogleVertexShared.oauth(input, project)
|
|
||||||
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,21 +121,16 @@ export const provider = {
|
|||||||
id,
|
id,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
|
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||||
return configure({
|
return configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||||
...(input.credential
|
baseURL: settings.baseURL,
|
||||||
? input.credential.type === "key"
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
? { apiKey: input.credential.value }
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
: { accessToken: input.credential.accessToken }
|
location: settings.location,
|
||||||
: input.settings.apiKey === undefined
|
project: settings.project,
|
||||||
? { accessToken: input.settings.accessToken }
|
providerOptions: settings.providerOptions,
|
||||||
: { apiKey: input.settings.apiKey }),
|
}).model(modelID)
|
||||||
baseURL: input.settings.baseURL,
|
|
||||||
location: input.settings.location,
|
|
||||||
project: input.settings.project,
|
|
||||||
providerOptions: input.settings.providerOptions,
|
|
||||||
}).model(input.id)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||||
import { Gemini } from "../protocols/gemini.js"
|
import { Gemini } from "../protocols/gemini.js"
|
||||||
import { GoogleImages } from "../protocols/google-images.js"
|
import { GoogleImages } from "../protocols/google-images.js"
|
||||||
@@ -28,11 +28,9 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||||
if ("auth" in options && options.auth) return options.auth
|
if ("auth" in options && options.auth) return options.auth
|
||||||
return Auth.remove("authorization").andThen(
|
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||||
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
|
||||||
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
|
.pipe(Auth.header("x-goog-api-key"))
|
||||||
.pipe(Auth.header("x-goog-api-key")),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredRoute = (input: Config) => {
|
const configuredRoute = (input: Config) => {
|
||||||
@@ -59,14 +57,13 @@ export const configure = (input: Config = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
|
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
configure({
|
configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential
|
baseURL: settings.baseURL,
|
||||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key")
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
: { apiKey: input.settings.apiKey }),
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
baseURL: input.settings.baseURL,
|
providerOptions: settings.providerOptions,
|
||||||
providerOptions: input.settings.providerOptions,
|
}).model(modelID)
|
||||||
}).model(input.id)
|
|
||||||
|
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
@@ -46,11 +46,15 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
|
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) =>
|
||||||
configure({
|
configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
provider: input.settings.provider,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
providerOptions: input.settings.providerOptions,
|
provider: settings.provider,
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
|
|||||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||||
|
|
||||||
@@ -68,14 +68,15 @@ export const provider = {
|
|||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
configure({
|
configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||||
provider: input.settings.provider,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
providerOptions: input.settings.providerOptions,
|
provider: settings.provider,
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
|
||||||
export const baseten = define(profiles.baseten)
|
export const baseten = define(profiles.baseten)
|
||||||
export const cerebras = define(profiles.cerebras)
|
export const cerebras = define(profiles.cerebras)
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||||
|
|
||||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||||
|
|
||||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||||
export type OpenAIConfigOptions = Options
|
readonly serviceTier?: OpenAIServiceTier
|
||||||
|
readonly [key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||||
|
|
||||||
|
const definedEntries = (input: Record<string, unknown>) =>
|
||||||
|
Object.entries(input).filter((entry) => entry[1] !== undefined)
|
||||||
|
|
||||||
|
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
definedEntries({
|
||||||
|
store: options?.store,
|
||||||
|
reasoningEffort: options?.reasoningEffort,
|
||||||
|
reasoningSummary: options?.reasoningSummary,
|
||||||
|
include: options?.include,
|
||||||
|
textVerbosity: options?.textVerbosity,
|
||||||
|
serviceTier: options?.serviceTier,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (Object.keys(result).length === 0) return undefined
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export const gpt5DefaultOptions = (
|
export const gpt5DefaultOptions = (
|
||||||
modelID: string,
|
modelID: string,
|
||||||
options: { readonly textVerbosity?: boolean } = {},
|
options: { readonly textVerbosity?: boolean } = {},
|
||||||
): ProviderOptions | undefined => {
|
): ProviderOptions | undefined => {
|
||||||
const id = modelID.toLowerCase()
|
const id = modelID.toLowerCase()
|
||||||
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
|
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
|
||||||
return {
|
return openAIProviderOptions({
|
||||||
reasoningEffort: "medium",
|
reasoningEffort: "medium",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||||
@@ -28,13 +48,14 @@ export const gpt5DefaultOptions = (
|
|||||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||||
? "low"
|
? "low"
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openAIDefaultOptions = (
|
export const openAIDefaultOptions = (
|
||||||
modelID: string,
|
modelID: string,
|
||||||
options: { readonly textVerbosity?: boolean } = {},
|
options: { readonly textVerbosity?: boolean } = {},
|
||||||
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options))
|
): ProviderOptions | undefined =>
|
||||||
|
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
|
||||||
|
|
||||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
||||||
modelID: string,
|
modelID: string,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||||
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||||
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
|
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
|
||||||
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
|
|||||||
// This provider facade wraps the lower-level Responses and Chat model factories
|
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||||
// and default option normalization.
|
// and default option normalization.
|
||||||
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type Config = RouteDefaultsInput &
|
||||||
OpenAIConfigOptions &
|
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly queryParams?: Record<string, string>
|
readonly queryParams?: Record<string, string>
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageGenerationOptions {
|
export interface ImageGenerationOptions {
|
||||||
@@ -57,12 +57,13 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly organization?: string
|
readonly organization?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly queryParams?: Readonly<Record<string, string>>
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||||
@@ -72,39 +73,6 @@ const defaults = (input: Config) => {
|
|||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
|
|
||||||
const {
|
|
||||||
instructions,
|
|
||||||
store,
|
|
||||||
reasoningEffort,
|
|
||||||
reasoningSummary,
|
|
||||||
include,
|
|
||||||
textVerbosity,
|
|
||||||
serviceTier,
|
|
||||||
truncation,
|
|
||||||
allowedTools,
|
|
||||||
maxToolCalls,
|
|
||||||
parallelToolCalls,
|
|
||||||
...rest
|
|
||||||
} = input
|
|
||||||
return {
|
|
||||||
options: {
|
|
||||||
instructions,
|
|
||||||
store,
|
|
||||||
reasoningEffort,
|
|
||||||
reasoningSummary,
|
|
||||||
include,
|
|
||||||
textVerbosity,
|
|
||||||
serviceTier,
|
|
||||||
truncation,
|
|
||||||
allowedTools,
|
|
||||||
maxToolCalls,
|
|
||||||
parallelToolCalls,
|
|
||||||
},
|
|
||||||
rest,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||||
route.with({
|
route.with({
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
@@ -114,8 +82,7 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
|||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||||
const split = splitConfigOptions(defaults(input))
|
const modelDefaults = defaults(input)
|
||||||
const modelDefaults = { ...split.rest, providerOptions: split.options }
|
|
||||||
const responses = (id: string | ModelID) =>
|
const responses = (id: string | ModelID) =>
|
||||||
responsesRoute
|
responsesRoute
|
||||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||||
@@ -146,30 +113,30 @@ export const configure = (input: Config = {}) => {
|
|||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
|
|
||||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
const config = (settings: Settings): Config => {
|
||||||
const settings = input.settings
|
|
||||||
const options = splitConfigOptions(settings).options
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||||
...input.defaults.headers,
|
...settings.headers,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
|
|
||||||
baseURL: settings.baseURL,
|
baseURL: settings.baseURL,
|
||||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||||
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||||
...options,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
return configure(config(input)).responses(input.id)
|
return configure(config(settings)).responses(modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
configure(config(input)).chat(input.id)
|
modelID,
|
||||||
|
settings,
|
||||||
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { Framing } from "../route/framing.js"
|
|||||||
import { Protocol } from "../route/protocol.js"
|
import { Protocol } from "../route/protocol.js"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||||
import { isRecord } from "../protocols/shared.js"
|
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||||
|
|
||||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||||
export const id = ProviderID.make(profile.provider)
|
export const id = ProviderID.make(profile.provider)
|
||||||
@@ -115,11 +115,12 @@ export const protocol = Protocol.make({
|
|||||||
reasoning_details: reasoningDetails,
|
reasoning_details: reasoningDetails,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||||
return {
|
return {
|
||||||
...body,
|
...body,
|
||||||
messages,
|
messages,
|
||||||
...bodyOptions(request.providerOptions),
|
...bodyOptions(request.providerOptions),
|
||||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||||
} as OpenRouterBody
|
} as OpenRouterBody
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -191,10 +192,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
|
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||||
|
modelID,
|
||||||
|
settings,
|
||||||
|
) =>
|
||||||
configure({
|
configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers,
|
||||||
providerOptions: input.settings.providerOptions,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
|||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||||
|
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||||
import { XAIImages } from "../protocols/xai-images.js"
|
import { XAIImages } from "../protocols/xai-images.js"
|
||||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||||
import { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
|
|
||||||
export const id = ProviderID.make("xai")
|
export const id = ProviderID.make("xai")
|
||||||
|
|
||||||
@@ -28,13 +29,19 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
|
|
||||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||||
|
|
||||||
|
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||||
|
|
||||||
const responsesRoute = Route.make({
|
const responsesRoute = Route.make({
|
||||||
id: "openai-responses",
|
id: "openai-responses",
|
||||||
provider: id,
|
provider: id,
|
||||||
providerMetadataKey: "xai",
|
providerMetadataKey: "xai",
|
||||||
protocol: OpenAIResponses.protocol,
|
protocol: XAIResponses.protocol,
|
||||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||||
transport: OpenAIResponses.httpTransport,
|
transport: OpenResponsesChannel.transport({
|
||||||
|
id: "openai-responses",
|
||||||
|
name: "xAI Responses",
|
||||||
|
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||||
|
}),
|
||||||
defaults: { providerOptions: { store: false } },
|
defaults: { providerOptions: { store: false } },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,13 +102,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const provider = configure()
|
export const provider = configure()
|
||||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
|
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||||
configure({
|
configure({
|
||||||
...ProviderPackage.routeDefaults(input.defaults),
|
apiKey: settings.apiKey,
|
||||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
baseURL: settings.baseURL,
|
||||||
baseURL: input.settings.baseURL,
|
headers: settings.headers,
|
||||||
providerOptions: input.settings.providerOptions,
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
}).model(input.id)
|
providerOptions: settings.providerOptions,
|
||||||
|
}).model(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
LLMRequest,
|
LLMRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
LanguageModel,
|
LanguageModel,
|
||||||
LanguageModelLimits,
|
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
InvalidProviderOutputReason,
|
InvalidProviderOutputReason,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
@@ -74,7 +73,6 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
|
|||||||
|
|
||||||
export interface RouteDefaults {
|
export interface RouteDefaults {
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly limits?: LanguageModelLimits
|
|
||||||
readonly generation?: GenerationOptions
|
readonly generation?: GenerationOptions
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: ProviderOptions
|
||||||
readonly http?: HttpOptions
|
readonly http?: HttpOptions
|
||||||
@@ -82,7 +80,6 @@ export interface RouteDefaults {
|
|||||||
|
|
||||||
export interface RouteDefaultsInput {
|
export interface RouteDefaultsInput {
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly limits?: LanguageModelLimits.Input
|
|
||||||
readonly generation?: GenerationOptions.Input
|
readonly generation?: GenerationOptions.Input
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: ProviderOptions
|
||||||
readonly http?: HttpOptions.Input
|
readonly http?: HttpOptions.Input
|
||||||
@@ -119,7 +116,6 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
|
|||||||
...base,
|
...base,
|
||||||
...patch,
|
...patch,
|
||||||
headers,
|
headers,
|
||||||
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
|
|
||||||
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
||||||
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
||||||
http: mergeHttpOptions(
|
http: mergeHttpOptions(
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ export interface Definition<Frame> {
|
|||||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||||
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
|
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
|
||||||
|
|
||||||
|
/** SSE framing restricted to protocol-recognized event names. */
|
||||||
|
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
|
||||||
|
id: "sse",
|
||||||
|
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
|
||||||
|
})
|
||||||
|
|
||||||
export * as Framing from "./framing.js"
|
export * as Framing from "./framing.js"
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
|||||||
module: Schema.String,
|
module: Schema.String,
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
reason: AIErrorReason,
|
reason: AIErrorReason,
|
||||||
|
// Raw provider payload as a string, so classified failures never lose the
|
||||||
|
// original error detail even when the pretty message is a summary.
|
||||||
|
body: Schema.optional(Schema.String),
|
||||||
}) {
|
}) {
|
||||||
override readonly cause = this.reason
|
override readonly cause = this.reason
|
||||||
|
|
||||||
|
|||||||
@@ -114,22 +114,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
|||||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
|
|
||||||
context: Schema.optional(Schema.Number),
|
|
||||||
input: Schema.optional(Schema.Number),
|
|
||||||
output: Schema.optional(Schema.Number),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export namespace LanguageModelLimits {
|
|
||||||
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
|
|
||||||
|
|
||||||
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
|
|
||||||
export const make = (input: Input | undefined) =>
|
|
||||||
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
|
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
|
||||||
limits: Schema.optional(LanguageModelLimits),
|
|
||||||
generation: Schema.optional(GenerationOptions),
|
generation: Schema.optional(GenerationOptions),
|
||||||
providerOptions: Schema.optional(ProviderOptions),
|
providerOptions: Schema.optional(ProviderOptions),
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
@@ -139,7 +124,6 @@ export namespace LanguageModelDefaults {
|
|||||||
export type Input =
|
export type Input =
|
||||||
| LanguageModelDefaults
|
| LanguageModelDefaults
|
||||||
| {
|
| {
|
||||||
readonly limits?: LanguageModelLimits.Input
|
|
||||||
readonly generation?: GenerationOptions.Input
|
readonly generation?: GenerationOptions.Input
|
||||||
readonly providerOptions?: ProviderOptions
|
readonly providerOptions?: ProviderOptions
|
||||||
readonly http?: HttpOptions.Input
|
readonly http?: HttpOptions.Input
|
||||||
@@ -149,7 +133,6 @@ export namespace LanguageModelDefaults {
|
|||||||
export const make = (input: Input) => {
|
export const make = (input: Input) => {
|
||||||
if (input instanceof LanguageModelDefaults) return input
|
if (input instanceof LanguageModelDefaults) return input
|
||||||
return new LanguageModelDefaults({
|
return new LanguageModelDefaults({
|
||||||
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
|
|
||||||
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
|
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
|
||||||
providerOptions: input.providerOptions,
|
providerOptions: input.providerOptions,
|
||||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
|||||||
@@ -81,22 +81,8 @@ OpenAI.configure({
|
|||||||
}).responses("gpt-4.1-mini")
|
}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
generation: { maxTokens: 100 },
|
generation: { maxTokens: 100 },
|
||||||
store: false,
|
providerOptions: { store: false },
|
||||||
}).responses("gpt-4.1-mini")
|
}).responses("gpt-4.1-mini")
|
||||||
OpenAI.model({
|
|
||||||
id: "gpt-5",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: "sk-test" },
|
|
||||||
defaults: { headers: { "x-test": "value" } },
|
|
||||||
})
|
|
||||||
OpenAI.model({
|
|
||||||
id: "gpt-5",
|
|
||||||
settings: {
|
|
||||||
// @ts-expect-error Common request defaults belong under input.defaults.
|
|
||||||
headers: { "x-test": "value" },
|
|
||||||
},
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// @ts-expect-error OpenAI model selectors only accept model ids.
|
// @ts-expect-error OpenAI model selectors only accept model ids.
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
|
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
|
||||||
@@ -111,7 +97,7 @@ OpenAI.configure({ bogus: true })
|
|||||||
OpenAI.configure({ generation: { maxTokens: "many" } })
|
OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||||
|
|
||||||
// @ts-expect-error provider-native options remain typed.
|
// @ts-expect-error provider-native options remain typed.
|
||||||
OpenAI.configure({ store: "false" })
|
OpenAI.configure({ providerOptions: { store: "false" } })
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
@@ -159,12 +145,8 @@ Anthropic.configure({
|
|||||||
}).model("claude-haiku")
|
}).model("claude-haiku")
|
||||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||||
Anthropic.model({
|
// @ts-expect-error Anthropic package settings accept only one auth source.
|
||||||
id: "claude-sonnet-4-6",
|
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||||
// @ts-expect-error Anthropic package settings accept only one auth source.
|
|
||||||
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
|
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
|
||||||
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
|
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
|
||||||
// @ts-expect-error Anthropic thinking budgets must be numbers.
|
// @ts-expect-error Anthropic thinking budgets must be numbers.
|
||||||
@@ -180,15 +162,11 @@ AnthropicCompatible.configure({
|
|||||||
AnthropicCompatible.configure({ apiKey: "messages-key" })
|
AnthropicCompatible.configure({ apiKey: "messages-key" })
|
||||||
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
|
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
|
||||||
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
|
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
|
||||||
AnthropicCompatible.model({
|
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
||||||
id: "compatible-model",
|
AnthropicCompatible.model("compatible-model", {
|
||||||
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
apiKey: "messages-key",
|
||||||
settings: {
|
authToken: "messages-token",
|
||||||
apiKey: "messages-key",
|
baseURL: "https://messages.example.com/v1",
|
||||||
authToken: "messages-token",
|
|
||||||
baseURL: "https://messages.example.com/v1",
|
|
||||||
},
|
|
||||||
defaults: {},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||||
@@ -211,23 +189,15 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
|
|||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||||
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||||
GoogleVertex.model({
|
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
|
||||||
id: "gemini-3.5-flash",
|
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||||
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
|
|
||||||
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
||||||
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"deepseek-ai/deepseek-v3.2-maas",
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
)
|
)
|
||||||
GoogleVertexChat.model({
|
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
|
||||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
|
||||||
settings: { apiKey: "vertex-key", project: "project" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"deepseek-ai/deepseek-v3.2-maas",
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
// @ts-expect-error Vertex Chat model selectors only accept model ids.
|
// @ts-expect-error Vertex Chat model selectors only accept model ids.
|
||||||
@@ -244,12 +214,8 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
|||||||
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"xai/grok-4.20-reasoning",
|
"xai/grok-4.20-reasoning",
|
||||||
)
|
)
|
||||||
GoogleVertexResponses.model({
|
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||||
id: "xai/grok-4.20-reasoning",
|
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
|
||||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
|
||||||
settings: { apiKey: "vertex-key", project: "project" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"xai/grok-4.20-reasoning",
|
"xai/grok-4.20-reasoning",
|
||||||
// @ts-expect-error Vertex Responses model selectors only accept model ids.
|
// @ts-expect-error Vertex Responses model selectors only accept model ids.
|
||||||
@@ -267,12 +233,8 @@ GoogleVertexMessages.configure({
|
|||||||
project: "project",
|
project: "project",
|
||||||
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
|
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
|
||||||
}).model("claude-sonnet-4-6")
|
}).model("claude-sonnet-4-6")
|
||||||
GoogleVertexMessages.model({
|
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||||
id: "claude-sonnet-4-6",
|
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
|
||||||
settings: { apiKey: "vertex-key", project: "project" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
|
|||||||
@@ -270,21 +270,20 @@ describe("request option precedence", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
it.effect("uses the Anthropic default before call maxTokens", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const route = AnthropicMessages.route.with({
|
const route = AnthropicMessages.route.with({
|
||||||
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
|
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
|
||||||
auth: Auth.header("x-api-key", "test"),
|
auth: Auth.header("x-api-key", "test"),
|
||||||
limits: { output: 128 },
|
|
||||||
})
|
})
|
||||||
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
|
const model = route.model({ id: "claude-sonnet-4-5" })
|
||||||
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
||||||
const withMaxTokens = yield* compileRequest(
|
const withMaxTokens = yield* compileRequest(
|
||||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
|
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(withoutMaxTokens.body.max_tokens).toBe(64)
|
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
|
||||||
expect(withMaxTokens.body.max_tokens).toBe(32)
|
expect(withMaxTokens.body.max_tokens).toBe(8_000)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:azure",
|
||||||
|
"provider:azure"
|
||||||
|
],
|
||||||
|
"name": "azure/chat-streams-text",
|
||||||
|
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:google-vertex",
|
||||||
|
"provider:google-vertex",
|
||||||
|
"protocol:gemini"
|
||||||
|
],
|
||||||
|
"name": "google-vertex/calls-a-tool",
|
||||||
|
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:google-vertex",
|
||||||
|
"provider:google-vertex",
|
||||||
|
"protocol:gemini"
|
||||||
|
],
|
||||||
|
"name": "google-vertex/continues-after-a-tool-result",
|
||||||
|
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"prefix:google-vertex",
|
||||||
|
"provider:google-vertex",
|
||||||
|
"protocol:gemini"
|
||||||
|
],
|
||||||
|
"name": "google-vertex/streams-text",
|
||||||
|
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
-5
File diff suppressed because one or more lines are too long
+100
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"tool",
|
||||||
|
"continuation"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"reconnect",
|
||||||
|
"full-context"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 1,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "openai",
|
||||||
|
"protocol": "openai-responses",
|
||||||
|
"transport": "websocket",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-responses-websocket",
|
||||||
|
"provider:openai",
|
||||||
|
"protocol:openai-responses",
|
||||||
|
"transport:websocket",
|
||||||
|
"continuation",
|
||||||
|
"recovery"
|
||||||
|
],
|
||||||
|
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
|
||||||
|
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 0,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "websocket",
|
||||||
|
"connection": {
|
||||||
|
"sequence": 1,
|
||||||
|
"url": "wss://api.openai.com/v1/responses",
|
||||||
|
"protocols": [],
|
||||||
|
"close": {
|
||||||
|
"code": 1000,
|
||||||
|
"reason": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "client",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "server",
|
||||||
|
"kind": "text",
|
||||||
|
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+57
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,6 +10,9 @@ export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
|
|||||||
|
|
||||||
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
|
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
|
||||||
|
|
||||||
|
export const sseNamedEvent = (event: string, data: unknown): string =>
|
||||||
|
`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an SSE body from already-serialized strings (used when the chunk shape
|
* Build an SSE body from already-serialized strings (used when the chunk shape
|
||||||
* itself is part of what's being tested, e.g. malformed chunks).
|
* itself is part of what's being tested, e.g. malformed chunks).
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ describe("llm constructors", () => {
|
|||||||
const model = chatRoute.model({
|
const model = chatRoute.model({
|
||||||
id: "kimi-k2",
|
id: "kimi-k2",
|
||||||
defaults: {
|
defaults: {
|
||||||
limits: { context: 128_000, output: 8_192 },
|
|
||||||
generation: { maxTokens: 1_024, stop: ["END"] },
|
generation: { maxTokens: 1_024, stop: ["END"] },
|
||||||
providerOptions: { parallelToolCalls: false },
|
providerOptions: { parallelToolCalls: false },
|
||||||
http: { body: { extra_body: true } },
|
http: { body: { extra_body: true } },
|
||||||
@@ -130,7 +129,6 @@ describe("llm constructors", () => {
|
|||||||
})
|
})
|
||||||
const request = LLM.request({ model, prompt: "Say hello." })
|
const request = LLM.request({ model, prompt: "Say hello." })
|
||||||
|
|
||||||
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
|
|
||||||
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
|
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
|
||||||
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
|
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
|
||||||
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
|
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ describe("provider error classification", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("classifies network error text as provider internal", () => {
|
||||||
|
expect(
|
||||||
|
["network error", "network-error", "network_error"].map(
|
||||||
|
(message) => classifyProviderFailure({ message })._tag,
|
||||||
|
),
|
||||||
|
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||||
|
})
|
||||||
|
|
||||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||||
expect(
|
expect(
|
||||||
[
|
[
|
||||||
@@ -98,3 +106,20 @@ describe("provider error classification", () => {
|
|||||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("provider error rawBody classification", () => {
|
||||||
|
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||||
|
const reason = classifyProviderFailure({
|
||||||
|
message: "Request failed",
|
||||||
|
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
|
||||||
|
})
|
||||||
|
expect(reason._tag).toBe("InvalidRequest")
|
||||||
|
expect(reason).toMatchObject({ classification: "context-overflow" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts nested codes from the raw payload", () => {
|
||||||
|
expect(
|
||||||
|
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||||
|
).toBe("QuotaExceeded")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
|||||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||||
|
|
||||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||||
|
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||||
LLM.request({
|
|
||||||
model,
|
|
||||||
prompt: "Hello",
|
|
||||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
|
||||||
providerOptions: { serviceTier: "premium" },
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
|||||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||||
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||||
|
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ConfigProvider, Effect } from "effect"
|
|
||||||
import { Headers } from "effect/unstable/http"
|
|
||||||
import { LLM, ProviderPackage } from "@opencode-ai/ai"
|
|
||||||
import { model } from "@opencode-ai/ai/providers/openai"
|
import { model } from "@opencode-ai/ai/providers/openai"
|
||||||
|
|
||||||
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
|
|
||||||
const { headers, body, limits, ...settings } = input
|
|
||||||
return { id, settings, defaults: { headers, body, limits } }
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeaders = (
|
|
||||||
selected: ReturnType<typeof model>,
|
|
||||||
headers: Record<string, string> = {},
|
|
||||||
env: Record<string, string> = {},
|
|
||||||
) =>
|
|
||||||
Effect.runPromise(
|
|
||||||
selected.route.auth
|
|
||||||
.apply({
|
|
||||||
request: LLM.request({ model: selected, prompt: "hello" }),
|
|
||||||
method: "POST",
|
|
||||||
url: "https://example.test/v1",
|
|
||||||
body: "{}",
|
|
||||||
headers: Headers.fromInput(headers),
|
|
||||||
})
|
|
||||||
.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyAuth = (
|
|
||||||
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
|
|
||||||
headers: Record<string, string> = {},
|
|
||||||
) => {
|
|
||||||
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
|
|
||||||
return Effect.runPromise(
|
|
||||||
option.auth.apply({
|
|
||||||
request: LLM.request({ model: selected, prompt: "hello" }),
|
|
||||||
method: "POST",
|
|
||||||
url: "https://example.test/v1",
|
|
||||||
body: "{}",
|
|
||||||
headers: Headers.fromInput(headers),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("provider package credential lowering", () => {
|
|
||||||
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
|
|
||||||
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
|
|
||||||
const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }))
|
|
||||||
|
|
||||||
expect(key.authorization).toBe("Bearer provider-key")
|
|
||||||
expect(oauth.authorization).toBe("Bearer provider-token")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
|
|
||||||
expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({
|
|
||||||
apiKey: "provider-key",
|
|
||||||
})
|
|
||||||
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
|
|
||||||
{ type: "oauth", accessToken: "provider-token" },
|
|
||||||
"x-api-key",
|
|
||||||
)
|
|
||||||
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
|
|
||||||
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
|
|
||||||
|
|
||||||
expect(headers.authorization).toBe("Bearer provider-token")
|
|
||||||
expect(headers["x-api-key"]).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("provider package entrypoints", () => {
|
describe("provider package entrypoints", () => {
|
||||||
test("semantic API aliases expose the same contract", async () => {
|
test("semantic API aliases expose the same contract", async () => {
|
||||||
const modules = await Promise.all([
|
const modules = await Promise.all([
|
||||||
@@ -109,177 +43,49 @@ describe("provider package entrypoints", () => {
|
|||||||
baseURL: "https://provider.example.test/v1",
|
baseURL: "https://provider.example.test/v1",
|
||||||
headers: { "x-application": "opencode" },
|
headers: { "x-application": "opencode" },
|
||||||
body: { service_tier: "priority" },
|
body: { service_tier: "priority" },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
|
||||||
}
|
}
|
||||||
const openrouter = OpenRouter.model(
|
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||||
packageInput("anthropic/claude-sonnet-4", {
|
...settings,
|
||||||
...settings,
|
providerOptions: { usage: true },
|
||||||
providerOptions: { usage: true },
|
})
|
||||||
}),
|
const xai = XAI.model("grok-4", {
|
||||||
)
|
...settings,
|
||||||
const xai = XAI.model(
|
providerOptions: { reasoningEffort: "high" },
|
||||||
packageInput("grok-4", {
|
})
|
||||||
...settings,
|
|
||||||
providerOptions: { reasoningEffort: "high" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const selected of [openrouter, xai]) {
|
for (const selected of [openrouter, xai]) {
|
||||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
|
||||||
}
|
}
|
||||||
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
|
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
|
||||||
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
|
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps package settings onto the executable model", () => {
|
test("maps package settings onto the executable model", () => {
|
||||||
const selected = model(
|
const selected = model("gpt-5", {
|
||||||
packageInput("gpt-5", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
baseURL: "https://api.openai.test/v1",
|
||||||
baseURL: "https://api.openai.test/v1",
|
headers: { "x-application": "opencode" },
|
||||||
headers: { "x-application": "opencode" },
|
body: { service_tier: "priority" },
|
||||||
body: { service_tier: "priority" },
|
unrelatedInheritedSetting: true,
|
||||||
limits: { context: 200_000, output: 64_000 },
|
})
|
||||||
reasoningEffort: "high",
|
|
||||||
unrelatedInheritedSetting: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(selected.route.id).toBe("openai-responses")
|
expect(selected.route.id).toBe("openai-responses")
|
||||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
||||||
expect(selected.route.defaults.providerOptions).toEqual({
|
|
||||||
store: false,
|
|
||||||
reasoningEffort: "high",
|
|
||||||
reasoningSummary: "auto",
|
|
||||||
include: ["reasoning.encrypted_content"],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("lets provider packages interpret resolved credentials", async () => {
|
|
||||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
|
||||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
|
||||||
const Google = await import("@opencode-ai/ai/providers/google")
|
|
||||||
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
|
||||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
|
||||||
const openai = model({
|
|
||||||
id: "gpt-5",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "oauth", accessToken: "openai-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const anthropicKey = Anthropic.model({
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: "anthropic-key" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const anthropicOAuth = Anthropic.model({
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "oauth", accessToken: "anthropic-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const anthropicEmptyKey = Anthropic.model({
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: "" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const azureKey = Azure.model({
|
|
||||||
id: "deployment",
|
|
||||||
settings: { resourceName: "opencode-test" },
|
|
||||||
credential: { type: "key", value: "azure-key" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const azureOAuth = Azure.model({
|
|
||||||
id: "deployment",
|
|
||||||
settings: { resourceName: "opencode-test" },
|
|
||||||
credential: { type: "oauth", accessToken: "azure-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const googleKey = Google.model({
|
|
||||||
id: "gemini-2.5-flash",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: "google-key" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const googleOAuth = Google.model({
|
|
||||||
id: "gemini-2.5-flash",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "oauth", accessToken: "google-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const vertexKey = GoogleVertex.model({
|
|
||||||
id: "gemini-3.5-flash",
|
|
||||||
settings: {},
|
|
||||||
credential: { type: "key", value: "vertex-key" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const vertexOAuth = GoogleVertex.model({
|
|
||||||
id: "gemini-3.5-flash",
|
|
||||||
settings: { project: "vertex-project" },
|
|
||||||
credential: { type: "oauth", accessToken: "vertex-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
const vertexChatOAuth = GoogleVertexChat.model({
|
|
||||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
|
||||||
settings: { apiKey: "configured-key", project: "vertex-project" },
|
|
||||||
credential: { type: "oauth", accessToken: "vertex-chat-token" },
|
|
||||||
defaults: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
|
|
||||||
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
|
|
||||||
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
|
|
||||||
const anthropicEmptyKeyHeaders = await authHeaders(
|
|
||||||
anthropicEmptyKey,
|
|
||||||
{ authorization: "Bearer stale" },
|
|
||||||
{ ANTHROPIC_API_KEY: "environment-key" },
|
|
||||||
)
|
|
||||||
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
|
|
||||||
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
|
|
||||||
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
|
|
||||||
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
|
|
||||||
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
|
|
||||||
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
|
|
||||||
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
|
|
||||||
expect(anthropicKeyHeaders.authorization).toBeUndefined()
|
|
||||||
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
|
|
||||||
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
|
|
||||||
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
|
|
||||||
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
|
|
||||||
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
|
|
||||||
expect(azureKeyHeaders.authorization).toBeUndefined()
|
|
||||||
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
|
|
||||||
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
|
|
||||||
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
|
|
||||||
expect(googleKeyHeaders.authorization).toBeUndefined()
|
|
||||||
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
|
|
||||||
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
|
|
||||||
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
|
|
||||||
expect(vertexKeyHeaders.authorization).toBeUndefined()
|
|
||||||
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
|
|
||||||
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
|
|
||||||
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||||
const selected = OpenAICompatibleResponses.model(
|
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||||
packageInput("custom-model", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
baseURL: "https://responses.example.test/v1",
|
||||||
baseURL: "https://responses.example.test/v1",
|
provider: "example",
|
||||||
provider: "example",
|
headers: { "x-application": "opencode" },
|
||||||
headers: { "x-application": "opencode" },
|
body: { service_tier: "priority" },
|
||||||
body: { service_tier: "priority" },
|
providerOptions: { reasoningEffort: "low", store: true },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
})
|
||||||
providerOptions: { reasoningEffort: "low", store: true },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(String(selected.provider)).toBe("example")
|
expect(String(selected.provider)).toBe("example")
|
||||||
expect(selected.route.id).toBe("openai-compatible-responses")
|
expect(selected.route.id).toBe("openai-compatible-responses")
|
||||||
@@ -289,23 +95,19 @@ describe("provider package entrypoints", () => {
|
|||||||
})
|
})
|
||||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
||||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
|
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
||||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||||
const selected = AnthropicCompatible.model(
|
const selected = AnthropicCompatible.model("compatible-model", {
|
||||||
packageInput("compatible-model", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
baseURL: "https://messages.example.test/v1",
|
||||||
baseURL: "https://messages.example.test/v1",
|
provider: "example",
|
||||||
provider: "example",
|
headers: { "x-application": "opencode" },
|
||||||
headers: { "x-application": "opencode" },
|
body: { metadata: { user_id: "user_1" } },
|
||||||
body: { metadata: { user_id: "user_1" } },
|
providerOptions: { effort: "low" },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
})
|
||||||
providerOptions: { effort: "low" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(String(selected.provider)).toBe("example")
|
expect(String(selected.provider)).toBe("example")
|
||||||
expect(selected.route.id).toBe("anthropic-messages")
|
expect(selected.route.id).toBe("anthropic-messages")
|
||||||
@@ -315,18 +117,15 @@ describe("provider package entrypoints", () => {
|
|||||||
})
|
})
|
||||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
||||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps Anthropic provider options onto the executable model", async () => {
|
test("maps Anthropic provider options onto the executable model", async () => {
|
||||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
||||||
const selected = Anthropic.model(
|
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||||
packageInput("claude-sonnet-4-6", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
providerOptions: { thinking: { type: "adaptive" } },
|
||||||
providerOptions: { thinking: { type: "adaptive" } },
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||||
})
|
})
|
||||||
@@ -334,7 +133,7 @@ describe("provider package entrypoints", () => {
|
|||||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]),
|
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -343,28 +142,25 @@ describe("provider package entrypoints", () => {
|
|||||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||||
packageInput("compatible-model", {
|
"compatible-model",
|
||||||
|
{
|
||||||
apiKey: "fixture",
|
apiKey: "fixture",
|
||||||
authToken: "token",
|
authToken: "token",
|
||||||
baseURL: "https://messages.example.test/v1",
|
baseURL: "https://messages.example.test/v1",
|
||||||
}),
|
},
|
||||||
]),
|
]),
|
||||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(Anthropic.model, undefined, [
|
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
|
|
||||||
]),
|
|
||||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||||
const selected = model(
|
const selected = model("gpt-5", {
|
||||||
packageInput("gpt-5", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
organization: "org_123",
|
||||||
organization: "org_123",
|
project: "proj_123",
|
||||||
project: "proj_123",
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(selected.route.defaults.headers).toMatchObject({
|
expect(selected.route.defaults.headers).toMatchObject({
|
||||||
"OpenAI-Organization": "org_123",
|
"OpenAI-Organization": "org_123",
|
||||||
@@ -381,37 +177,31 @@ describe("provider package entrypoints", () => {
|
|||||||
resourceName: "opencode-test",
|
resourceName: "opencode-test",
|
||||||
headers: { "x-application": "opencode" },
|
headers: { "x-application": "opencode" },
|
||||||
body: { service_tier: "priority" },
|
body: { service_tier: "priority" },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const responses = AzureResponses.model(packageInput("deployment", settings))
|
const responses = AzureResponses.model("deployment", settings)
|
||||||
const chat = AzureChat.model(packageInput("deployment", settings))
|
const chat = AzureChat.model("deployment", settings)
|
||||||
|
|
||||||
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
|
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
||||||
expect(responses.route.id).toBe("azure-openai-responses")
|
expect(responses.route.id).toBe("azure-openai-responses")
|
||||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||||
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
|
||||||
expect(chat.route.id).toBe("azure-openai-chat")
|
expect(chat.route.id).toBe("azure-openai-chat")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
||||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||||
const deployment = Azure.model(
|
const deployment = Azure.model("custom-deployment", {
|
||||||
packageInput("custom-deployment", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
resourceName: "opencode-test",
|
||||||
resourceName: "opencode-test",
|
apiVersion: "2025-01-01-preview",
|
||||||
apiVersion: "2025-01-01-preview",
|
useDeploymentBasedUrls: true,
|
||||||
useDeploymentBasedUrls: true,
|
})
|
||||||
}),
|
const gateway = Azure.model("gateway-model", {
|
||||||
)
|
apiKey: "fixture",
|
||||||
const gateway = Azure.model(
|
baseURL: "https://gateway.example/azure/",
|
||||||
packageInput("gateway-model", {
|
})
|
||||||
apiKey: "fixture",
|
|
||||||
baseURL: "https://gateway.example/azure/",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(deployment.route.endpoint).toMatchObject({
|
expect(deployment.route.endpoint).toMatchObject({
|
||||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||||
@@ -423,22 +213,18 @@ describe("provider package entrypoints", () => {
|
|||||||
|
|
||||||
test("maps Google package settings onto the Gemini model", async () => {
|
test("maps Google package settings onto the Gemini model", async () => {
|
||||||
const Google = await import("@opencode-ai/ai/providers/google")
|
const Google = await import("@opencode-ai/ai/providers/google")
|
||||||
const selected = Google.model(
|
const selected = Google.model("gemini-2.5-flash", {
|
||||||
packageInput("gemini-2.5-flash", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
baseURL: "https://generativelanguage.test/v1beta",
|
||||||
baseURL: "https://generativelanguage.test/v1beta",
|
headers: { "x-application": "opencode" },
|
||||||
headers: { "x-application": "opencode" },
|
body: { safetySettings: [] },
|
||||||
body: { safetySettings: [] },
|
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||||
limits: { context: 1_000_000, output: 65_536 },
|
})
|
||||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(selected.route.id).toBe("gemini")
|
expect(selected.route.id).toBe("gemini")
|
||||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||||
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
|
||||||
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
|
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -448,35 +234,26 @@ describe("provider package entrypoints", () => {
|
|||||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
||||||
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
||||||
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
||||||
const gemini = GoogleVertex.model(
|
const gemini = GoogleVertex.model("gemini-3.5-flash", {
|
||||||
packageInput("gemini-3.5-flash", {
|
apiKey: "fixture",
|
||||||
apiKey: "fixture",
|
headers: { "x-application": "opencode" },
|
||||||
headers: { "x-application": "opencode" },
|
body: { safetySettings: [] },
|
||||||
body: { safetySettings: [] },
|
})
|
||||||
limits: { context: 1_000_000, output: 65_536 },
|
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
|
||||||
}),
|
accessToken: "fixture",
|
||||||
)
|
location: "global",
|
||||||
const messages = GoogleVertexMessages.model(
|
project: "vertex-project",
|
||||||
packageInput("claude-sonnet-4-6", {
|
})
|
||||||
accessToken: "fixture",
|
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
|
||||||
location: "global",
|
accessToken: "fixture",
|
||||||
project: "vertex-project",
|
location: "global",
|
||||||
}),
|
project: "vertex-project",
|
||||||
)
|
})
|
||||||
const chat = GoogleVertexChat.model(
|
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
|
||||||
packageInput("deepseek-ai/deepseek-v3.2-maas", {
|
accessToken: "fixture",
|
||||||
accessToken: "fixture",
|
location: "global",
|
||||||
location: "global",
|
project: "vertex-project",
|
||||||
project: "vertex-project",
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
const responses = GoogleVertexResponses.model(
|
|
||||||
packageInput("xai/grok-4.20-reasoning", {
|
|
||||||
accessToken: "fixture",
|
|
||||||
location: "global",
|
|
||||||
project: "vertex-project",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||||
@@ -484,15 +261,12 @@ describe("provider package entrypoints", () => {
|
|||||||
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
||||||
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||||
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||||
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
|
||||||
expect(
|
expect(
|
||||||
GoogleVertex.model(
|
GoogleVertex.model("gemini-3.5-flash", {
|
||||||
packageInput("gemini-3.5-flash", {
|
accessToken: "fixture",
|
||||||
accessToken: "fixture",
|
location: "eu",
|
||||||
location: "eu",
|
project: "vertex-project",
|
||||||
project: "vertex-project",
|
}).route.endpoint.baseURL,
|
||||||
}),
|
|
||||||
).route.endpoint.baseURL,
|
|
||||||
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
||||||
expect(messages.route.id).toBe("google-vertex-messages")
|
expect(messages.route.id).toBe("google-vertex-messages")
|
||||||
expect(messages.route.protocol).toBe("anthropic-messages")
|
expect(messages.route.protocol).toBe("anthropic-messages")
|
||||||
@@ -522,11 +296,8 @@ describe("provider package entrypoints", () => {
|
|||||||
const Providers = await import("@opencode-ai/ai/providers")
|
const Providers = await import("@opencode-ai/ai/providers")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(GoogleVertex.model, undefined, [
|
Reflect.apply(GoogleVertex.model, undefined, [
|
||||||
packageInput("gemini-3.5-flash", {
|
"gemini-3.5-flash",
|
||||||
accessToken: "token",
|
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||||
apiKey: "fixture",
|
|
||||||
project: "vertex-project",
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||||
@@ -535,7 +306,8 @@ describe("provider package entrypoints", () => {
|
|||||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
|
"claude-sonnet-4-6",
|
||||||
|
{ apiKey: "fixture", project: "vertex-project" },
|
||||||
]),
|
]),
|
||||||
).toThrow("Google Vertex Messages does not support API keys")
|
).toThrow("Google Vertex Messages does not support API keys")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -545,7 +317,8 @@ describe("provider package entrypoints", () => {
|
|||||||
).toThrow("Google Vertex Messages does not support API keys")
|
).toThrow("Google Vertex Messages does not support API keys")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||||
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
|
{ apiKey: "fixture", project: "vertex-project" },
|
||||||
]),
|
]),
|
||||||
).toThrow("Google Vertex Chat does not support API keys")
|
).toThrow("Google Vertex Chat does not support API keys")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -555,7 +328,8 @@ describe("provider package entrypoints", () => {
|
|||||||
).toThrow("Google Vertex Chat does not support API keys")
|
).toThrow("Google Vertex Chat does not support API keys")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||||
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
|
"xai/grok-4.20-reasoning",
|
||||||
|
{ apiKey: "fixture", project: "vertex-project" },
|
||||||
]),
|
]),
|
||||||
).toThrow("Google Vertex Responses does not support API keys")
|
).toThrow("Google Vertex Responses does not support API keys")
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
|||||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
|
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
|
||||||
import { it } from "../lib/effect.js"
|
import { it } from "../lib/effect.js"
|
||||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||||
import { sseEvents } from "../lib/sse.js"
|
import { sseEvents, sseNamedEvent, sseRaw } from "../lib/sse.js"
|
||||||
|
|
||||||
const model = AnthropicMessages.route
|
const model = AnthropicMessages.route
|
||||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||||
@@ -322,11 +322,34 @@ describe("Anthropic Messages route", () => {
|
|||||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
|
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
|
||||||
],
|
],
|
||||||
stream: true,
|
stream: true,
|
||||||
max_tokens: 4096,
|
max_tokens: 32_000,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("scrubs outbound tool call IDs without truncating them", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const id = `functions.lookup:1|${"x".repeat(64)}`
|
||||||
|
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
|
||||||
|
Message.tool({ id, name: "lookup", result: "done" }),
|
||||||
|
],
|
||||||
|
cache: "none",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toMatchObject([
|
||||||
|
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
|
||||||
|
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
|
||||||
|
])
|
||||||
|
expect(scrubbed.length).toBeGreaterThan(64)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -640,7 +663,60 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
it.effect("ignores unknown named SSE events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseRaw(
|
||||||
|
sseNamedEvent("message_start", {
|
||||||
|
type: "message_start",
|
||||||
|
message: { usage: { input_tokens: 5 } },
|
||||||
|
}),
|
||||||
|
sseNamedEvent("proxy.stats", "not json"),
|
||||||
|
sseNamedEvent("content_block_start", {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
}),
|
||||||
|
sseNamedEvent("content_block_delta", {
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Hello" },
|
||||||
|
}),
|
||||||
|
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
|
||||||
|
sseNamedEvent("message_delta", {
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn" },
|
||||||
|
usage: { output_tokens: 1 },
|
||||||
|
}),
|
||||||
|
sseNamedEvent("message_stop", { type: "message_stop" }),
|
||||||
|
sseNamedEvent("proxy.done", "still not json"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects malformed recognized SSE events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.reason).toMatchObject({
|
||||||
|
_tag: "InvalidProviderOutput",
|
||||||
|
message: "Invalid anthropic/anthropic-messages stream event",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
@@ -663,6 +739,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
type: "message_delta",
|
type: "message_delta",
|
||||||
delta: { stop_reason: "end_turn" },
|
delta: { stop_reason: "end_turn" },
|
||||||
usage: {
|
usage: {
|
||||||
|
input_tokens: null,
|
||||||
output_tokens: 8,
|
output_tokens: 8,
|
||||||
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
||||||
output_tokens_details: { terminal_detail: "preserved" },
|
output_tokens_details: { terminal_detail: "preserved" },
|
||||||
@@ -682,7 +759,7 @@ describe("Anthropic Messages route", () => {
|
|||||||
totalTokens: 15,
|
totalTokens: 15,
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
input_tokens: 5,
|
input_tokens: null,
|
||||||
cache_read_input_tokens: 2,
|
cache_read_input_tokens: 2,
|
||||||
service_tier: "standard",
|
service_tier: "standard",
|
||||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||||
@@ -1339,14 +1416,14 @@ describe("Anthropic Messages route", () => {
|
|||||||
Message.assistant([
|
Message.assistant([
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "srvtoolu_abc",
|
id: "srvtoolu.abc",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
input: { query: "effect 4" },
|
input: { query: "effect 4" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
id: "srvtoolu_abc",
|
id: "srvtoolu.abc",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||||
|
import { Azure } from "../../src/providers.js"
|
||||||
|
import { LLMClient } from "../../src/route.js"
|
||||||
|
import { recordedTests } from "../recorded-test.js"
|
||||||
|
|
||||||
|
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
|
||||||
|
|
||||||
|
const chatModel = Azure.configure({
|
||||||
|
resourceName,
|
||||||
|
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||||
|
}).chat("gpt-5.6-luna")
|
||||||
|
|
||||||
|
const responsesModel = Azure.configure({
|
||||||
|
resourceName,
|
||||||
|
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||||
|
}).responses("gpt-5.6-luna")
|
||||||
|
|
||||||
|
const lookupWeather = ToolDefinition.make({
|
||||||
|
name: "lookup_weather",
|
||||||
|
description: "Look up the current weather for a city",
|
||||||
|
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "azure",
|
||||||
|
provider: "azure",
|
||||||
|
requires: ["AZURE_OPENAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Azure OpenAI recorded", () => {
|
||||||
|
recorded.effect("chat streams text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.toLowerCase()).toContain("hello")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("responses streams text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.toLowerCase()).toContain("bonjour")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("responses calls a tool", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: responsesModel,
|
||||||
|
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||||
|
tools: [lookupWeather],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||||
|
expect(call).toBeDefined()
|
||||||
|
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("responses continues after a tool result", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: responsesModel,
|
||||||
|
messages: [
|
||||||
|
Message.user("What is the weather in Paris?"),
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||||
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_paris_1",
|
||||||
|
name: "lookup_weather",
|
||||||
|
result: "18C, light rain",
|
||||||
|
resultType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tools: [lookupWeather],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -139,6 +139,190 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps system updates separate from function responses", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||||
|
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||||
|
Message.system("Update."),
|
||||||
|
Message.system("Later update."),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "done" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: "<system-update>\nUpdate.\n</system-update>" },
|
||||||
|
{ text: "<system-update>\nLater update.\n</system-update>" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("merges parallel tool results into one function-response turn", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||||
|
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
|
||||||
|
]),
|
||||||
|
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
|
||||||
|
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||||
|
{ functionCall: { name: "lookup", args: { query: "time" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "sunny" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "noon" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lowers function call ids for gemini 3 models", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: gemini3,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||||
|
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "done" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const messages = [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||||
|
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||||
|
]
|
||||||
|
const legacy = yield* compileRequest(LLM.request({ model, messages }))
|
||||||
|
const older = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: Gemini.route
|
||||||
|
.with({
|
||||||
|
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||||
|
auth: Auth.header("x-goog-api-key", "test"),
|
||||||
|
})
|
||||||
|
.model({ id: "gemini-1.5-flash" }),
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(legacy.body.contents).toEqual([
|
||||||
|
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
|
||||||
|
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("includes function call ids for non-gemini model ids", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: Gemini.route
|
||||||
|
.with({
|
||||||
|
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||||
|
auth: Auth.header("x-goog-api-key", "test"),
|
||||||
|
})
|
||||||
|
.model({ id: "gemma-3-27b-it" }),
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||||
|
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("prepares multimodal user input and tool history", () =>
|
it.effect("prepares multimodal user input and tool history", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -239,14 +423,18 @@ describe("Gemini route", () => {
|
|||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: "read",
|
name: "read",
|
||||||
response: { name: "read", content: "Image read successfully" },
|
response: { name: "read", content: "Image read successfully" },
|
||||||
parts: [
|
|
||||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
|
||||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: "Attached media from tool result:" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||||
|
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
|
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
|
||||||
}),
|
}),
|
||||||
@@ -279,11 +467,164 @@ describe("Gemini route", () => {
|
|||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: "read",
|
name: "read",
|
||||||
response: { name: "read", content: "" },
|
response: { name: "read", content: "" },
|
||||||
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: "Attached media from tool result:" },
|
||||||
|
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("nests media inside function responses for gemini 3", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: gemini3,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({
|
||||||
|
id: "call_image",
|
||||||
|
name: "read",
|
||||||
|
input: { path: "pixel.png" },
|
||||||
|
providerMetadata: { google: { thoughtSignature: "sig_1" } },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_image",
|
||||||
|
name: "read",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "Image read successfully" },
|
||||||
|
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: "call_image",
|
||||||
|
name: "read",
|
||||||
|
response: { name: "read", content: "Image read successfully" },
|
||||||
|
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("flushes pending media before system update text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "shot",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Message.system("Update."),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: "Attached media from tool result:" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||||
|
{ text: "<system-update>\nUpdate.\n</system-update>" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("collects legacy tool media into one turn after merged responses", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
|
||||||
|
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
|
||||||
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "shot",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_2",
|
||||||
|
name: "shot",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "text", text: "no image here" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{ functionCall: { name: "shot", args: {} } },
|
||||||
|
{ functionCall: { name: "shot", args: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
|
||||||
|
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: "Attached media from tool result:" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -605,8 +946,8 @@ describe("Gemini route", () => {
|
|||||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||||
})
|
})
|
||||||
expect(toolCall).toMatchObject({
|
expect(toolCall).toMatchObject({
|
||||||
id: "tool_0",
|
id: "provider_call",
|
||||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
|
||||||
})
|
})
|
||||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||||
response.events.findIndex((event) => event.type === "tool-call"),
|
response.events.findIndex((event) => event.type === "tool-call"),
|
||||||
@@ -614,23 +955,22 @@ describe("Gemini route", () => {
|
|||||||
|
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model: gemini3,
|
||||||
messages: [
|
messages: [
|
||||||
Message.assistant([
|
Message.assistant([
|
||||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||||
ToolCallPart.make({
|
ToolCallPart.make({
|
||||||
id: "tool_0",
|
id: "provider_call",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
input: { query: "weather" },
|
input: { query: "weather" },
|
||||||
providerMetadata: toolCall?.providerMetadata,
|
providerMetadata: toolCall?.providerMetadata,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
Message.tool({
|
Message.tool({
|
||||||
id: "tool_0",
|
id: "provider_call",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
result: "done",
|
result: "done",
|
||||||
resultType: "text",
|
resultType: "text",
|
||||||
providerMetadata: toolCall?.providerMetadata,
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -662,6 +1002,61 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves thoughtSignature on visible text parts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
const delta = response.events.find((event) => event.type === "text-delta")
|
||||||
|
expect(delta).toMatchObject({
|
||||||
|
id: "text-0",
|
||||||
|
text: "All done.",
|
||||||
|
providerMetadata: { google: { thoughtSignature: "text_sig" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("flushes a trailing empty signed text part at block close", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: "model",
|
||||||
|
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
|
||||||
|
},
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
const delta = response.events.find((event) => event.type === "text-delta")
|
||||||
|
const end = response.events.find((event) => event.type === "text-end")
|
||||||
|
|
||||||
|
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
|
||||||
|
expect(end).toMatchObject({
|
||||||
|
id: "text-0",
|
||||||
|
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -679,7 +1074,7 @@ describe("Gemini route", () => {
|
|||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||||
thoughtSignature: "skip_thought_signature_validator",
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -689,7 +1084,7 @@ describe("Gemini route", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
id: undefined,
|
id: "tool_0",
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
response: { name: "lookup", content: "done" },
|
response: { name: "lookup", content: "done" },
|
||||||
},
|
},
|
||||||
@@ -725,15 +1120,15 @@ describe("Gemini route", () => {
|
|||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||||
thoughtSignature: "parallel_signature",
|
thoughtSignature: "parallel_signature",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||||
thoughtSignature: undefined,
|
thoughtSignature: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||||
thoughtSignature: undefined,
|
thoughtSignature: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -761,11 +1156,11 @@ describe("Gemini route", () => {
|
|||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||||
thoughtSignature: "skip_thought_signature_validator",
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||||
thoughtSignature: "skip_thought_signature_validator",
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -803,21 +1198,17 @@ describe("Gemini route", () => {
|
|||||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.toolCalls).toEqual([
|
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
{
|
expect(response.toolCalls[0]).toMatchObject({
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "tool_0",
|
name: "lookup",
|
||||||
name: "lookup",
|
input: { query: "weather" },
|
||||||
input: { query: "weather" },
|
})
|
||||||
providerExecuted: undefined,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "tool_0",
|
id: response.toolCalls[0].id,
|
||||||
name: "lookup",
|
name: "lookup",
|
||||||
input: { query: "weather" },
|
input: { query: "weather" },
|
||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
@@ -860,7 +1251,8 @@ describe("Gemini route", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
|
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
|
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -900,7 +1292,7 @@ describe("Gemini route", () => {
|
|||||||
content: {
|
content: {
|
||||||
role: "model",
|
role: "model",
|
||||||
parts: [
|
parts: [
|
||||||
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
|
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
|
||||||
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -914,16 +1306,19 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(fixedResponse(body)))
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
expect(response.toolCalls).toEqual([
|
expect(response.toolCalls[0]).toMatchObject({
|
||||||
{
|
type: "tool-call",
|
||||||
type: "tool-call",
|
id: "call_0",
|
||||||
id: "tool_0",
|
name: "lookup",
|
||||||
name: "lookup",
|
input: { query: "weather" },
|
||||||
input: { query: "weather" },
|
})
|
||||||
providerMetadata: { google: { functionCallId: "tool_0" } },
|
expect(response.toolCalls[1]).toMatchObject({
|
||||||
},
|
type: "tool-call",
|
||||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
name: "lookup",
|
||||||
])
|
input: { query: "news" },
|
||||||
|
})
|
||||||
|
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
|
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
reason: { normalized: "tool-calls", raw: "STOP" },
|
||||||
@@ -931,6 +1326,62 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
|
||||||
|
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.toolCalls[0]).toMatchObject({
|
||||||
|
id: "dup_call",
|
||||||
|
providerMetadata: undefined,
|
||||||
|
})
|
||||||
|
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
|
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: "model",
|
||||||
|
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||||
|
},
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const req = LLMRequest.update(request, {
|
||||||
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
|
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||||
|
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps length and content-filter finish reasons", () =>
|
it.effect("maps length and content-filter finish reasons", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const length = yield* LLMClient.generate(request).pipe(
|
const length = yield* LLMClient.generate(request).pipe(
|
||||||
@@ -1041,6 +1492,73 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("survives explicit null usage counts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
|
||||||
|
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("Hi")
|
||||||
|
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
|
||||||
|
expect(response.usage?.inputTokens).toBeUndefined()
|
||||||
|
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
|
||||||
|
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
|
||||||
|
expect(response.usage?.reasoningTokens).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("survives null candidates, content, parts, and finish reason", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ candidates: null },
|
||||||
|
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||||
|
{ candidates: [{ content: null, finishReason: null }] },
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("Hello")
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats a null thought flag on a text part as visible output", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
|
||||||
|
|
||||||
|
expect(reasoningStart).toBeUndefined()
|
||||||
|
expect(response.reasoning ?? "").toBe("")
|
||||||
|
expect(response.text).toBe("Visible")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails invalid stream events", () =>
|
it.effect("fails invalid stream events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
|
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||||
|
import { GoogleVertex } from "../../src/providers.js"
|
||||||
|
import { LLMClient } from "../../src/route.js"
|
||||||
|
import { recordedTests } from "../recorded-test.js"
|
||||||
|
|
||||||
|
const model = GoogleVertex.configure({
|
||||||
|
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
|
||||||
|
}).model("gemini-3.5-flash")
|
||||||
|
|
||||||
|
const lookupWeather = ToolDefinition.make({
|
||||||
|
name: "lookup_weather",
|
||||||
|
description: "Look up the current weather for a city",
|
||||||
|
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "google-vertex",
|
||||||
|
provider: "google-vertex",
|
||||||
|
protocol: "gemini",
|
||||||
|
requires: ["GOOGLE_VERTEX_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Google Vertex Gemini recorded", () => {
|
||||||
|
recorded.effect("streams text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.toLowerCase()).toContain("hello")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("calls a tool", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||||
|
tools: [lookupWeather],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||||
|
expect(call).toBeDefined()
|
||||||
|
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("continues after a tool result", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user("What is the weather in Paris?"),
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||||
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_paris_1",
|
||||||
|
name: "lookup_weather",
|
||||||
|
result: "18C, light rain",
|
||||||
|
resultType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tools: [lookupWeather],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM } from "../../src/index.js"
|
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
||||||
import { LLMClient } from "../../src/route.js"
|
import { LLMClient } from "../../src/route.js"
|
||||||
import { compileRequest } from "../../src/route/client.js"
|
import { compileRequest } from "../../src/route/client.js"
|
||||||
@@ -75,6 +75,53 @@ describe("Google Vertex providers", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: GoogleVertex.configure({
|
||||||
|
accessToken: "vertex-token",
|
||||||
|
project: "vertex-project",
|
||||||
|
}).model("gemini-3.5-flash"),
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
result: "sunny",
|
||||||
|
resultType: "text",
|
||||||
|
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||||
|
expect(prepared.body.contents).toMatchObject([
|
||||||
|
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
id: undefined,
|
||||||
|
name: "lookup",
|
||||||
|
response: { name: "lookup", content: "sunny" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = GoogleVertexMessages.configure({
|
const model = GoogleVertexMessages.configure({
|
||||||
@@ -96,7 +143,7 @@ describe("Google Vertex providers", () => {
|
|||||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
||||||
)
|
)
|
||||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||||
expect(request.headers.get("anthropic-version")).toBeNull()
|
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
|
||||||
const body = yield* Effect.promise(() => request.json())
|
const body = yield* Effect.promise(() => request.json())
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
anthropic_version: "vertex-2023-10-16",
|
anthropic_version: "vertex-2023-10-16",
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
},
|
},
|
||||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||||
],
|
],
|
||||||
|
tools: [],
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
})
|
})
|
||||||
@@ -664,6 +665,74 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves streamed refusals as ordinary assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({ role: "assistant", refusal: "I can't" }),
|
||||||
|
deltaChunk({ refusal: " help with that." }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("I can't help with that.")
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
|
||||||
|
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
|
||||||
|
|
||||||
|
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("orders metadata-only reasoning before refusal output", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: [] } }] },
|
||||||
|
deltaChunk({ refusal: "I can't help with that." }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "I can't help with that.",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({ refusal: "No." }),
|
||||||
|
deltaChunk({ content: " Alternative." }),
|
||||||
|
deltaChunk({ refusal: " Still no." }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("No. Alternative. Still no.")
|
||||||
|
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
|
||||||
|
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||||
|
|||||||
@@ -73,7 +73,46 @@ describe("Open Responses-compatible route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||||
{ role: "developer", content: "Operator update." },
|
{ role: "developer", content: "Operator update." },
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = configure({
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: "https://responses.example.test/v1",
|
||||||
|
provider: "example",
|
||||||
|
}).model("example-model")
|
||||||
|
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
|
||||||
|
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
resultType: "content",
|
||||||
|
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
|
||||||
|
},
|
||||||
|
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
|
||||||
|
{
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: "call_1",
|
||||||
|
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -93,7 +132,7 @@ describe("Open Responses-compatible route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = configure({
|
const model = configure({
|
||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
@@ -113,11 +152,74 @@ describe("Open Responses-compatible route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toMatchObject({
|
||||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Unclassified." }],
|
||||||
|
phase: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves standard refusal content as ordinary assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = configure({
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: "https://responses.example.test/v1",
|
||||||
|
provider: "example",
|
||||||
|
}).model("example-model")
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "msg_refusal", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.refusal.done",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
refusal: "I can't help with that.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "msg_refusal",
|
||||||
|
content: [{ type: "refusal", refusal: "I can't help with that." }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "I can't help with that.",
|
||||||
|
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_refusal",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("reads standard Open Responses options", () =>
|
it.effect("reads standard Open Responses options", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = configure({
|
const model = configure({
|
||||||
@@ -126,7 +228,12 @@ describe("Open Responses-compatible route", () => {
|
|||||||
providerOptions: {
|
providerOptions: {
|
||||||
reasoningEffort: "low",
|
reasoningEffort: "low",
|
||||||
store: true,
|
store: true,
|
||||||
|
metadata: { environment: "test" },
|
||||||
|
safetyIdentifier: "user_123",
|
||||||
|
streamOptions: { includeObfuscation: false },
|
||||||
|
topLogprobs: 3,
|
||||||
truncation: "auto",
|
truncation: "auto",
|
||||||
|
serviceTier: "provider-tier",
|
||||||
allowedTools: { toolNames: ["lookup"] },
|
allowedTools: { toolNames: ["lookup"] },
|
||||||
maxToolCalls: 2,
|
maxToolCalls: 2,
|
||||||
parallelToolCalls: false,
|
parallelToolCalls: false,
|
||||||
@@ -136,6 +243,7 @@ describe("Open Responses-compatible route", () => {
|
|||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model,
|
||||||
prompt: "Think.",
|
prompt: "Think.",
|
||||||
|
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
|
||||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -143,7 +251,14 @@ describe("Open Responses-compatible route", () => {
|
|||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toMatchObject({
|
||||||
reasoning: { effort: "low" },
|
reasoning: { effort: "low" },
|
||||||
store: true,
|
store: true,
|
||||||
|
metadata: { environment: "test" },
|
||||||
|
safety_identifier: "user_123",
|
||||||
|
stream_options: { include_obfuscation: false },
|
||||||
|
top_logprobs: 3,
|
||||||
|
presence_penalty: 0.2,
|
||||||
|
frequency_penalty: -0.1,
|
||||||
truncation: "auto",
|
truncation: "auto",
|
||||||
|
service_tier: "provider-tier",
|
||||||
tool_choice: {
|
tool_choice: {
|
||||||
type: "allowed_tools",
|
type: "allowed_tools",
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
|
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
|
||||||
|
import {
|
||||||
|
LLMClient,
|
||||||
|
WebSocketTransport,
|
||||||
|
type ChannelCheckpoint,
|
||||||
|
type ChannelObservation,
|
||||||
|
type WebSocketChannelExchange,
|
||||||
|
type WebSocketChannelExecutor,
|
||||||
|
type WebSocketConnection,
|
||||||
|
} from "../../src/route.js"
|
||||||
|
import { configure } from "../../src/providers/openai.js"
|
||||||
|
import { decodeJson } from "../../src/protocols/shared.js"
|
||||||
|
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
|
||||||
|
import { recordedTests } from "../recorded-test.js"
|
||||||
|
|
||||||
|
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-responses-websocket",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: ["OPENAI_API_KEY"],
|
||||||
|
tags: ["transport:websocket"],
|
||||||
|
metadata: { transport: "websocket", model: model.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const observationFrame = (observation: ChannelObservation) => {
|
||||||
|
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||||
|
return Effect.succeed(observation.frame)
|
||||||
|
return Effect.fail(observation.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||||
|
|
||||||
|
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
|
||||||
|
const makeChannel = Effect.gen(function* () {
|
||||||
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
|
let connection: WebSocketConnection | undefined
|
||||||
|
let checkpoint: ChannelCheckpoint | undefined
|
||||||
|
let pending: ChannelCheckpoint | undefined
|
||||||
|
let opens = 0
|
||||||
|
const sent: unknown[] = []
|
||||||
|
|
||||||
|
const close = Effect.suspend(() => {
|
||||||
|
const current = connection
|
||||||
|
connection = undefined
|
||||||
|
return current ? current.close : Effect.void
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => close)
|
||||||
|
|
||||||
|
const executor: WebSocketChannelExecutor = {
|
||||||
|
execute: (exchange: WebSocketChannelExchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!connection) {
|
||||||
|
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
|
||||||
|
Effect.provideService(Socket.WebSocketConstructor, constructor),
|
||||||
|
)
|
||||||
|
opens += 1
|
||||||
|
}
|
||||||
|
const current = connection
|
||||||
|
const create = yield* exchange.driver.create(checkpoint)
|
||||||
|
if (create.mode === "full") checkpoint = undefined
|
||||||
|
pending = undefined
|
||||||
|
sent.push(decodeJson(create.message))
|
||||||
|
yield* current.sendText(create.message)
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
return {
|
||||||
|
frames: current.messages.pipe(
|
||||||
|
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
|
||||||
|
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||||
|
Stream.tap((observation) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (!terminal(observation)) return
|
||||||
|
pending = observation.type === "completed" ? observation.checkpoint : undefined
|
||||||
|
if (observation.type !== "completed") checkpoint = undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Stream.takeUntil(terminal),
|
||||||
|
Stream.mapEffect(observationFrame),
|
||||||
|
),
|
||||||
|
complete: Effect.sync(() => {
|
||||||
|
checkpoint = pending
|
||||||
|
pending = undefined
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
executor,
|
||||||
|
sent,
|
||||||
|
opens: () => opens,
|
||||||
|
reconnect: (preserveCheckpoint = false) =>
|
||||||
|
close.pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending = undefined
|
||||||
|
if (!preserveCheckpoint) checkpoint = undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("OpenAI Responses WebSocket recorded", () => {
|
||||||
|
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_tool",
|
||||||
|
model,
|
||||||
|
system: "Call get_weather once, then reply exactly: Paris is sunny.",
|
||||||
|
prompt: "What is the weather in Paris?",
|
||||||
|
tools: [weatherTool],
|
||||||
|
generation: { maxTokens: 50 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
const call = first.toolCalls[0]
|
||||||
|
if (!call) yield* Effect.die("Expected get_weather tool call")
|
||||||
|
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
|
||||||
|
const second = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
messages: [
|
||||||
|
...request.messages,
|
||||||
|
first.message,
|
||||||
|
Message.tool({ id: call.id, name: call.name, result: result.result }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ webSocket: channel.executor },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(second.text).toBe("Paris is sunny.")
|
||||||
|
expect(channel.opens()).toBe(1)
|
||||||
|
expect(channel.sent).toHaveLength(2)
|
||||||
|
expect(channel.sent[1]).toMatchObject({
|
||||||
|
previous_response_id: expect.any(String),
|
||||||
|
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_reconnect",
|
||||||
|
model,
|
||||||
|
system: "Follow the user's exact reply instruction.",
|
||||||
|
prompt: "Reply exactly: Alpha.",
|
||||||
|
generation: { maxTokens: 30 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
yield* channel.reconnect()
|
||||||
|
const second = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
|
||||||
|
}),
|
||||||
|
{ webSocket: channel.executor },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first.text).toBe("Alpha.")
|
||||||
|
expect(second.text).toBe("Beta.")
|
||||||
|
expect(channel.opens()).toBe(2)
|
||||||
|
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
|
||||||
|
expect(channel.sent[1]).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const channel = yield* makeChannel
|
||||||
|
const request = LLM.request({
|
||||||
|
id: "recorded_openai_responses_websocket_rejection",
|
||||||
|
model,
|
||||||
|
system: "Follow the user's exact reply instruction.",
|
||||||
|
prompt: "Reply exactly: Ready.",
|
||||||
|
generation: { maxTokens: 30 },
|
||||||
|
cache: "none",
|
||||||
|
})
|
||||||
|
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||||
|
const continuation = LLMRequest.update(request, {
|
||||||
|
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
|
||||||
|
})
|
||||||
|
yield* channel.reconnect(true)
|
||||||
|
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
|
||||||
|
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
|
||||||
|
|
||||||
|
expect(rejected).toMatchObject({
|
||||||
|
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
|
||||||
|
})
|
||||||
|
expect(recovered.text).toBe("Recovered.")
|
||||||
|
expect(channel.opens()).toBe(2)
|
||||||
|
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
|
||||||
|
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
|
||||||
|
expect(channel.sent[2]).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
|
|||||||
import * as OpenAI from "../../src/providers/openai.js"
|
import * as OpenAI from "../../src/providers/openai.js"
|
||||||
import * as XAI from "../../src/providers/xai.js"
|
import * as XAI from "../../src/providers/xai.js"
|
||||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||||
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
|
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||||
import { it } from "../lib/effect.js"
|
import { it } from "../lib/effect.js"
|
||||||
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
|||||||
|
|
||||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||||
const message = ProviderShared.encodeJson(request)
|
const message = ProviderShared.encodeJson(request)
|
||||||
return OpenAIResponsesChannel.driver({
|
return OpenResponsesContinuation.driver({
|
||||||
id: "openai-responses",
|
id: "openai-responses",
|
||||||
name: "OpenAI Responses",
|
name: "OpenAI Responses",
|
||||||
request,
|
request,
|
||||||
@@ -188,17 +188,15 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("omits unsupported semantic service tiers", () =>
|
it.effect("passes through provider-defined service tiers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
expect(prepared.body.service_tier).toBe("scale")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("flattens top-level object unions in function schemas", () =>
|
it.effect("preserves function schemas", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
@@ -238,13 +236,22 @@ describe("OpenAI Responses route", () => {
|
|||||||
strict: false,
|
strict: false,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
anyOf: [
|
||||||
path: { type: "string" },
|
{
|
||||||
reference: { type: "string" },
|
type: "object",
|
||||||
limit: { type: "integer", maximum: 2000 },
|
properties: {
|
||||||
resource: { type: "string" },
|
path: { type: "string" },
|
||||||
},
|
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
additionalProperties: false,
|
limit: { type: "integer", maximum: 2000 },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
|
||||||
|
required: ["resource"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -263,7 +270,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||||
{ role: "developer", content: "Operator update." },
|
{ role: "developer", content: "Operator update." },
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -394,6 +401,38 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("tolerates keepalive frames before response.created", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const webSocket = WebSocketTransport.makeDirect({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: Stream.fromArray([
|
||||||
|
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||||
|
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||||
|
ProviderShared.encodeJson({
|
||||||
|
type: "response.completed",
|
||||||
|
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
close: Effect.void,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const deps = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
)
|
||||||
|
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
|
||||||
|
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||||
|
)
|
||||||
|
expect(response.finishReason?.normalized).toBe("stop")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("continues a tool call with only the new tool output", () =>
|
it.effect("continues a tool call with only the new tool output", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const firstRequest = {
|
const firstRequest = {
|
||||||
@@ -485,7 +524,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
|
it.effect("continues store-false reasoning while retaining the output item ID", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
|
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
|
||||||
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
|
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
|
||||||
@@ -515,6 +554,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
...firstInput,
|
...firstInput,
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
summary: [{ type: "summary_text", text: "Thought" }],
|
summary: [{ type: "summary_text", text: "Thought" }],
|
||||||
encrypted_content: "encrypted",
|
encrypted_content: "encrypted",
|
||||||
},
|
},
|
||||||
@@ -690,6 +730,134 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const deps = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
|
||||||
|
webSocket: {
|
||||||
|
execute: (exchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
|
||||||
|
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
|
||||||
|
expect(exchange.connect.headers.authorization).toBe("Bearer test")
|
||||||
|
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||||
|
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||||
|
type: "response.create",
|
||||||
|
model: "grok-4.5",
|
||||||
|
store: false,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
frames: Stream.make(
|
||||||
|
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
|
||||||
|
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
|
||||||
|
),
|
||||||
|
complete: Effect.void,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||||
|
|
||||||
|
expect(response.finishReason.normalized).toBe("stop")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const deps = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
)
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
|
||||||
|
authorization: "Bearer azure-key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
|
||||||
|
"deployment",
|
||||||
|
),
|
||||||
|
authorization: "Bearer entra-token",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* Effect.forEach(cases, (item) =>
|
||||||
|
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||||
|
webSocket: {
|
||||||
|
execute: (exchange) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
|
||||||
|
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
|
||||||
|
expect(exchange.connect.headers.authorization).toBe(item.authorization)
|
||||||
|
expect(exchange.connect.headers["api-key"]).toBeUndefined()
|
||||||
|
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
|
||||||
|
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
|
||||||
|
type: "response.create",
|
||||||
|
model: "deployment",
|
||||||
|
store: false,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
frames: Stream.make(
|
||||||
|
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
|
||||||
|
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
|
||||||
|
),
|
||||||
|
complete: Effect.void,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
model: Azure.configure({
|
||||||
|
resourceName: "opencode-test",
|
||||||
|
apiKey: "azure-key",
|
||||||
|
apiVersion: "2025-04-01-preview",
|
||||||
|
}).responses("deployment"),
|
||||||
|
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Azure.configure({
|
||||||
|
resourceName: "opencode-test",
|
||||||
|
apiKey: "azure-key",
|
||||||
|
useDeploymentBasedUrls: true,
|
||||||
|
}).responses("deployment"),
|
||||||
|
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
|
||||||
|
"deployment",
|
||||||
|
),
|
||||||
|
url: "https://gateway.example/azure/responses",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* Effect.forEach(cases, (item) =>
|
||||||
|
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
|
||||||
|
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(input.request.url).toBe(item.url)
|
||||||
|
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
|
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const attempts = yield* Ref.make(0)
|
const attempts = yield* Ref.make(0)
|
||||||
@@ -1176,7 +1344,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -1204,8 +1372,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "input_file",
|
type: "input_file",
|
||||||
filename: "report.pdf",
|
filename: "report.pdf",
|
||||||
file_data: "JVBERi0xLjQ=",
|
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
mime_type: "application/pdf",
|
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
@@ -1235,6 +1402,60 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "fetch",
|
||||||
|
resultType: "content",
|
||||||
|
result: [
|
||||||
|
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
|
||||||
|
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||||
|
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||||
|
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lowers tool-result videos as input_video", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "record",
|
||||||
|
resultType: "content",
|
||||||
|
result: [
|
||||||
|
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
|
||||||
|
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||||
|
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
|
||||||
|
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("prepares the composed native continuation request", () =>
|
it.effect("prepares the composed native continuation request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
@@ -1284,6 +1505,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||||
prompt: "think",
|
prompt: "think",
|
||||||
promptCacheKey: "session_123",
|
promptCacheKey: "session_123",
|
||||||
|
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
|
||||||
tools: [
|
tools: [
|
||||||
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
||||||
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
|
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
|
||||||
@@ -1293,6 +1515,10 @@ describe("OpenAI Responses route", () => {
|
|||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
include: ["reasoning.encrypted_content"],
|
include: ["reasoning.encrypted_content"],
|
||||||
|
metadata: { environment: "test", tenant: "acme" },
|
||||||
|
safetyIdentifier: "user_123",
|
||||||
|
streamOptions: { includeObfuscation: false },
|
||||||
|
topLogprobs: 5,
|
||||||
truncation: "disabled",
|
truncation: "disabled",
|
||||||
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
|
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
|
||||||
maxToolCalls: 4,
|
maxToolCalls: 4,
|
||||||
@@ -1306,6 +1532,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||||
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
||||||
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
||||||
|
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
|
||||||
|
expect(prepared.body.safety_identifier).toBe("user_123")
|
||||||
|
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
|
||||||
|
expect(prepared.body.top_logprobs).toBe(5)
|
||||||
|
expect(prepared.body.presence_penalty).toBe(0.25)
|
||||||
|
expect(prepared.body.frequency_penalty).toBe(-0.25)
|
||||||
expect(prepared.body.truncation).toBe("disabled")
|
expect(prepared.body.truncation).toBe("disabled")
|
||||||
expect(prepared.body.tool_choice).toEqual({
|
expect(prepared.body.tool_choice).toEqual({
|
||||||
type: "allowed_tools",
|
type: "allowed_tools",
|
||||||
@@ -1473,7 +1705,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.events).toEqual([
|
expect(response.events).toEqual([
|
||||||
{ type: "step-start", index: 0 },
|
{ type: "step-start", index: 0 },
|
||||||
{ type: "text-start", id: "msg_1" },
|
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||||
{ type: "text-end", id: "msg_1" },
|
{ type: "text-end", id: "msg_1" },
|
||||||
@@ -1494,6 +1726,108 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves standard refusal content as ordinary assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "msg_refusal", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "refusal", refusal: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.refusal.delta",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
delta: "I can't",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.refusal.delta",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
delta: " help with that.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.refusal.done",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
refusal: "I can't help with that.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.done",
|
||||||
|
item_id: "msg_refusal",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "refusal", refusal: "I can't help with that." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "msg_refusal",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [{ type: "refusal", refusal: "I can't help with that." }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("I can't help with that.")
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "I can't help with that.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_refusal",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||||
|
phase: "final_answer",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects malformed refusal events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = [
|
||||||
|
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
|
||||||
|
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
|
||||||
|
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
|
||||||
|
]
|
||||||
|
for (const event of events) {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(fixedResponse(sseEvents(event))),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves and replays assistant message phases", () =>
|
it.effect("preserves and replays assistant message phases", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
@@ -1532,33 +1866,39 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Checking.",
|
text: "Checking.",
|
||||||
providerMetadata: { openai: { phase: "commentary" } },
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Finished.",
|
text: "Finished.",
|
||||||
providerMetadata: { openai: { phase: "final_answer" } },
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Unclassified.",
|
text: "Unclassified.",
|
||||||
providerMetadata: { openai: { phase: null } },
|
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_commentary",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "output_text", text: "Checking." }],
|
content: [{ type: "output_text", text: "Checking." }],
|
||||||
phase: "commentary",
|
phase: "commentary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_final",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "output_text", text: "Finished." }],
|
content: [{ type: "output_text", text: "Finished." }],
|
||||||
phase: "final_answer",
|
phase: "final_answer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_null",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "output_text", text: "Unclassified." }],
|
content: [{ type: "output_text", text: "Unclassified." }],
|
||||||
phase: null,
|
phase: null,
|
||||||
@@ -1652,12 +1992,12 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||||
{ type: "text-start", id: "msg_1" },
|
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||||
{ type: "text-end", id: "msg_1" },
|
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
|
||||||
{ type: "text-start", id: "msg_2" },
|
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||||
{ type: "text-end", id: "msg_2" },
|
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1689,7 +2029,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
expect(response.message.content).toEqual([
|
expect(response.message.content).toEqual([
|
||||||
{ type: "reasoning", text: "thinking" },
|
{ type: "reasoning", text: "thinking" },
|
||||||
{ type: "text", text: "Hello" },
|
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1850,6 +2190,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||||
},
|
},
|
||||||
@@ -1857,7 +2198,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(body.input[1]).not.toHaveProperty("id")
|
|
||||||
return input.respond(
|
return input.respond(
|
||||||
sseEvents(
|
sseEvents(
|
||||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||||
@@ -1901,13 +2241,14 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1970,6 +2311,44 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("continues stateless hosted tool results with their text form", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user("Search."),
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({
|
||||||
|
id: "ws_1",
|
||||||
|
name: "web_search",
|
||||||
|
input: { query: "effect 4" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "ws_1",
|
||||||
|
name: "web_search",
|
||||||
|
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
Message.user("Continue."),
|
||||||
|
],
|
||||||
|
providerOptions: { store: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||||
@@ -2039,6 +2418,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [
|
summary: [
|
||||||
{ type: "summary_text", text: "First" },
|
{ type: "summary_text", text: "First" },
|
||||||
@@ -2171,6 +2551,50 @@ describe("OpenAI Responses route", () => {
|
|||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("finalizes a pending function call at response completion", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "tool-input-end",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: {},
|
||||||
|
providerExecuted: undefined,
|
||||||
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2237,6 +2661,35 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("retains function call item metadata when output_item.added is absent", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||||
|
id: "call_1",
|
||||||
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
@@ -2276,6 +2729,47 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const item = {
|
||||||
|
type: "computer_call",
|
||||||
|
id: "computer_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
status: "completed",
|
||||||
|
action: { type: "click", x: 100, y: 200 },
|
||||||
|
}
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.output_item.done", item },
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "computer_1",
|
||||||
|
name: "computer_use",
|
||||||
|
input: { type: "click", x: 100, y: 200 },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "computer_1",
|
||||||
|
name: "computer_use",
|
||||||
|
result: { type: "json", value: item },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes image generation output as image content", () =>
|
it.effect("decodes image generation output as image content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
@@ -2397,7 +2891,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -2420,8 +2914,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "input_file",
|
type: "input_file",
|
||||||
filename: "report.pdf",
|
filename: "report.pdf",
|
||||||
file_data: "JVBERi0xLjQ=",
|
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||||
mime_type: "application/pdf",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -2454,6 +2947,37 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers remote user media URLs without base64 wrapping", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user([
|
||||||
|
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||||
|
{
|
||||||
|
type: "media",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
data: "https://example.com/report.pdf",
|
||||||
|
filename: "report.pdf",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||||
|
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails with a typed rate limit for provider error frames", () =>
|
it.effect("fails with a typed rate limit for provider error frames", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
@@ -2610,36 +3134,42 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when error is null", () =>
|
it.effect("falls back to the raw payload when error is null", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||||
|
expect(error.reason.message).toContain('"error":null')
|
||||||
|
expect(error.body).toBe(error.reason.message)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
it.effect("classifies a detail-free error event as a transient provider failure", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
|
||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||||
|
expect(error.reason.message).toContain('"type":"error"')
|
||||||
|
expect(error.body).toBe(error.reason.message)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||||
Effect.flip,
|
Effect.flip,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||||
|
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||||
|
expect(error.body).toBe(error.reason.message)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
|
|||||||
id: "xai",
|
id: "xai",
|
||||||
name: "xAI Grok 4.5",
|
name: "xAI Grok 4.5",
|
||||||
provider: "xai",
|
provider: "xai",
|
||||||
protocol: "openai-responses",
|
protocol: "xai-responses",
|
||||||
requires: "XAI_API_KEY",
|
requires: "XAI_API_KEY",
|
||||||
filename: "verification.pdf",
|
filename: "verification.pdf",
|
||||||
maxTokens: 40,
|
maxTokens: 40,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLM, LLMEvent } from "../../src/index.js"
|
||||||
|
import { XAI } from "../../src/providers.js"
|
||||||
|
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||||
|
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||||
|
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||||
|
import { LLMClient } from "../../src/route.js"
|
||||||
|
import { compileRequest } from "../../src/route/client.js"
|
||||||
|
import { it } from "../lib/effect.js"
|
||||||
|
import { fixedResponse } from "../lib/http.js"
|
||||||
|
import { sseEvents } from "../lib/sse.js"
|
||||||
|
|
||||||
|
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||||
|
|
||||||
|
describe("xAI Responses route", () => {
|
||||||
|
it.effect("extends the Open Responses baseline directly", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
|
||||||
|
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||||
|
|
||||||
|
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||||
|
expect(prepared.protocol).toBe("xai-responses")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("parses xAI reasoning text events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
|
||||||
|
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "response_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Considering.",
|
||||||
|
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("parses xAI hosted tool items", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "response_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||||
|
id: "x_search_1",
|
||||||
|
name: "x_search",
|
||||||
|
input: { query: "news" },
|
||||||
|
providerExecuted: true,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
|
import { NodeSocket } from "@effect/platform-node"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||||
@@ -16,7 +18,7 @@ import {
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||||
|
|
||||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
...metadata,
|
...metadata,
|
||||||
}
|
}
|
||||||
if (recording) {
|
if (recording) {
|
||||||
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
|
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
|
||||||
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
|
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
|
||||||
}
|
}
|
||||||
const requestExecutor = RequestExecutor.layer.pipe(
|
const requestExecutor = RequestExecutor.layer.pipe(
|
||||||
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
|
||||||
|
...recorderOptions,
|
||||||
|
directory: FIXTURES_DIR,
|
||||||
|
metadata: recorderMetadata,
|
||||||
|
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
requestExecutor,
|
requestExecutor,
|
||||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
|
webSocket,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
|
|||||||
success: Schema.String,
|
success: Schema.String,
|
||||||
execute: () => Effect.succeed("hello"),
|
execute: () => Effect.succeed("hello"),
|
||||||
})
|
})
|
||||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
|
||||||
const dispatched = yield* ToolRuntime.dispatch(
|
const dispatched = yield* ToolRuntime.dispatch(
|
||||||
{ tool },
|
{ tool },
|
||||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user