mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48779a5444 | ||
|
|
0c77f6ed5b | ||
|
|
284b222489 | ||
|
|
705606face | ||
|
|
b738ef970d | ||
|
|
4112698e72 | ||
|
|
a609174969 | ||
|
|
d3694a5383 | ||
|
|
fafcea42e6 | ||
|
|
95c3c3f962 | ||
|
|
497a24c17d | ||
|
|
f1521000ec | ||
|
|
8a3e9890d2 | ||
|
|
2142de87dd | ||
|
|
367cf59612 | ||
|
|
2b8a3e7ee0 | ||
|
|
6dbb5f5008 | ||
|
|
8d1d1ee982 | ||
|
|
c33dc8808b | ||
|
|
5253603583 | ||
|
|
5fb3040505 | ||
|
|
df7dae3706 | ||
|
|
83ff4253f5 | ||
|
|
89cb5146d4 | ||
|
|
b0b5bf6749 | ||
|
|
ef9b9fb6bf | ||
|
|
c05d481ec6 | ||
|
|
8e726dc7d8 | ||
|
|
23f0ee53cf | ||
|
|
46aa25f236 | ||
|
|
803ead32e7 | ||
|
|
42422a1e03 | ||
|
|
b1374978c9 | ||
|
|
ed95fdaa27 | ||
|
|
53a4829672 | ||
|
|
7036294543 | ||
|
|
23506b5fb4 | ||
|
|
2af02d0ad7 | ||
|
|
26ee104829 | ||
|
|
8252897a33 | ||
|
|
7000607fd0 | ||
|
|
fa1ab5f8e1 | ||
|
|
e288e0fc4d | ||
|
|
ff5b5d00f9 | ||
|
|
990f7ee000 | ||
|
|
e2d6c4bd4a | ||
|
|
fcc6568fcb | ||
|
|
1c66cd7832 | ||
|
|
5cc81a497c | ||
|
|
10786cb60c | ||
|
|
1e7c60adce | ||
|
|
1c4f8c40a8 | ||
|
|
2ca55b479d | ||
|
|
8d7caa178b | ||
|
|
2bcb67a71e | ||
|
|
48d4e52143 | ||
|
|
40cbea3c19 | ||
|
|
51065122d8 | ||
|
|
7507f19a00 | ||
|
|
71706577c4 | ||
|
|
66f326ddec | ||
|
|
83b81b39f1 | ||
|
|
b20e4971db | ||
|
|
c3378f2d34 | ||
|
|
d68031bc0d | ||
|
|
4ac19d744a | ||
|
|
b5cb5e2a55 | ||
|
|
53d171781a | ||
|
|
3184427c03 | ||
|
|
6b0613ac6f | ||
|
|
5ac4156eb2 | ||
|
|
954cdc7bc8 | ||
|
|
767115cd69 | ||
|
|
5b25ee8430 | ||
|
|
df6317d23c | ||
|
|
524e39db02 | ||
|
|
6170221e21 | ||
|
|
74a0b2d0ad | ||
|
|
9d46941e70 | ||
|
|
d48a5d16ff | ||
|
|
6c392355dd | ||
|
|
dd4ee94682 | ||
|
|
009eca8b5b | ||
|
|
94bd9f6c8b | ||
|
|
33909f48d7 | ||
|
|
80653a0a1a | ||
|
|
f7913a04d2 | ||
|
|
cf347cd5e4 | ||
|
|
6600d59635 | ||
|
|
8b6a2450d5 | ||
|
|
1aa4046f02 | ||
|
|
9bd69fe847 | ||
|
|
ded8a492d1 | ||
|
|
018b4c40f3 | ||
|
|
0772b67b7a | ||
|
|
a841d6d046 | ||
|
|
ab6a01d135 | ||
|
|
962a6ca0e7 | ||
|
|
2602dcd0a7 | ||
|
|
cf98ca55c9 | ||
|
|
fedf017e25 | ||
|
|
f4a9b93013 | ||
|
|
cbef698861 | ||
|
|
ab2d251155 | ||
|
|
3d7ba38965 | ||
|
|
37a6ba893e | ||
|
|
6c6871fd2a | ||
|
|
1e864dd8c6 | ||
|
|
21980a4448 | ||
|
|
9cca8dd6e0 | ||
|
|
91028a690b | ||
|
|
e82aa92e64 | ||
|
|
874538d702 | ||
|
|
afc26c72c0 | ||
|
|
d572a5756c | ||
|
|
ab95695b24 | ||
|
|
5d7c2ccfc0 | ||
|
|
24ac05868c | ||
|
|
4eaf533cd0 | ||
|
|
0f7a76eff0 | ||
|
|
16d731bd67 | ||
|
|
ea582fc133 | ||
|
|
7e27e81bc7 | ||
|
|
667722897e | ||
|
|
d12dbd12a9 | ||
|
|
b0bd0bc394 | ||
|
|
437df1164c | ||
|
|
9a91e21a76 | ||
|
|
d01ac2069a | ||
|
|
a78d3c5438 | ||
|
|
474c3588c1 | ||
|
|
9361117504 | ||
|
|
5add6a8e19 | ||
|
|
f3c390b89e | ||
|
|
185c3e5136 | ||
|
|
cb863b8ea5 | ||
|
|
695c043e6b | ||
|
|
d53456da3b | ||
|
|
64e930628d | ||
|
|
3c73ce1dc7 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
---
|
||||
|
||||
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Make the experimental portable shell scanner authoritative, with no Tree-sitter
|
||||
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
|
||||
expressions, quoting, and substitutions natively. Preserve existing redirect and
|
||||
declaration permission matching, and make PowerShell saved approvals cover the
|
||||
original command spelling. Parser failures remain visible without changing the
|
||||
permission engine. The default Tree-sitter path is unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Find affected packages
|
||||
id: packages
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
@@ -237,13 +237,19 @@ jobs:
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
- name: Run app e2e tests against production build
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
run: bun --cwd packages/app test:e2e:built
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Verify service worker precaching and upgrades
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
@@ -347,10 +348,8 @@
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -365,6 +364,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -553,6 +553,20 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode-ai/latex",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
@@ -731,6 +745,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -836,6 +851,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@playwright/test": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "10.4.4",
|
||||
"@storybook/addon-docs": "10.4.4",
|
||||
@@ -877,6 +893,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/latex": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -1178,8 +1195,6 @@
|
||||
|
||||
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8pcrQvEQCbvrrQKnD6hclBbI0hUgSrgyADykRbabxv/g9vPurfMC6n23J7dD+KZ3EcCoW+qz3IUIfySJ58gBOg=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||
|
||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||
|
||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZgkausouWvO9U4ZtowNJ093bSNYOvH8zqls3uLC3+oxzWvbbTZO8SOdmFk0+gGafsXFJvq2yUX9+rEeJPwOJLw=="],
|
||||
@@ -2150,6 +2165,8 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
@@ -2158,6 +2175,20 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
@@ -5910,10 +5941,6 @@
|
||||
|
||||
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
|
||||
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
|
||||
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
|
||||
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
|
||||
"x86_64-linux": "sha256-agxF1Ab8P84tijHvw7jaG+9UZm/dJZhHw8CGNlQQqYc=",
|
||||
"aarch64-linux": "sha256-Kg6CkhoEEsb+ZOGPN/rnNoirLdGSyQl6HU/Fy8t9Mqs=",
|
||||
"aarch64-darwin": "sha256-SnxBpmcfPL2p3OYmSuG5Y/aFW8PeEOLhfQrxAs/53fs=",
|
||||
"x86_64-darwin": "sha256-Dnxl1EcfiE7/rlHmPkko0BKc6hHVk6rQ/2h/PWuLfNo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
|
||||
|
||||
## Errors
|
||||
|
||||
- `AIError` wraps a union of tagged reason errors. It stores only `reason`, derives `message` from the reason, and exposes the reason as its `cause`.
|
||||
- Each reason owns its readable `message`, category-specific fields, and optional `body`, `http`, and underlying exception in `cause`.
|
||||
- `reason.body` is the sole original-response or triggering-event payload field. Preserve original text before schema decoding removes fields; do not replace the complete event with only its nested error.
|
||||
- `reason.http` describes an observed HTTP response with required `url`, `status`, and response `headers`. Do not invent status codes or derive a separate request ID from headers.
|
||||
- Reclassification and transport recovery must preserve the reason's body, HTTP context, and underlying cause. Error `message` and `cause` are non-enumerable: copy them explicitly when constructing an enriched reason with its constructor or `AIErrorReason.make`.
|
||||
|
||||
## Architecture
|
||||
|
||||
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
|
||||
|
||||
@@ -36,7 +36,12 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
HttpOptions,
|
||||
InvalidRequestReason,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
@@ -158,9 +158,10 @@ export function generate(input: ImageRequest | ImageRequestInput) {
|
||||
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
module: "Image",
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LLMClient, Service } from "./route/client.js"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidProviderOutputError,
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -116,9 +116,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new AIError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
})
|
||||
@@ -126,10 +124,9 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -69,14 +69,22 @@ export interface OptionsInput {
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?: string | { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
readonly outputConfig?: { readonly effort?: string | null; readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null }
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
@@ -259,7 +267,11 @@ const AnthropicToolChoice = Schema.Union([
|
||||
type: Schema.Literals(["auto", "any", "none"]),
|
||||
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("tool"),
|
||||
name: Schema.String,
|
||||
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
@@ -394,6 +406,7 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
@@ -428,18 +441,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.signature === "string" ? provider.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
@@ -499,14 +512,21 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
providerMetadataKey: string,
|
||||
) {
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
|
||||
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
|
||||
return {
|
||||
type: wireType,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
content: payload,
|
||||
} satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
|
||||
@@ -554,9 +574,7 @@ const documentContextFromMetadata = (metadata: MediaPart["metadata"]): string |
|
||||
return undefined
|
||||
}
|
||||
|
||||
const citationsFromMetadata = (
|
||||
metadata: MediaPart["metadata"],
|
||||
): AnthropicDocumentBlock["citations"] | undefined => {
|
||||
const citationsFromMetadata = (metadata: MediaPart["metadata"]): AnthropicDocumentBlock["citations"] | undefined => {
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const raw = ProviderShared.isRecord(metadata.anthropic)
|
||||
? (metadata.anthropic.citations ?? metadata.citations)
|
||||
@@ -706,8 +724,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
|
||||
})
|
||||
|
||||
const requireThinkingSignature = (request: LLMRequest) => {
|
||||
if (request.model.compatibility?.requireSignature !== undefined)
|
||||
return request.model.compatibility.requireSignature
|
||||
if (request.model.compatibility?.requireSignature !== undefined) return request.model.compatibility.requireSignature
|
||||
const provider = request.model.provider.toLowerCase()
|
||||
const model = request.model.id.toLowerCase()
|
||||
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
|
||||
@@ -791,6 +808,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
@@ -836,8 +854,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (part.type === "reasoning") {
|
||||
// A signature marks visible thinking; only signature-less parts carrying
|
||||
// redactedData round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
@@ -866,7 +884,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted) {
|
||||
content.push(yield* lowerServerToolResult(part))
|
||||
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
return yield* invalid(
|
||||
@@ -900,21 +918,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier = (input as Record<string, unknown> | undefined)?.service_tier ?? (input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) &&
|
||||
(typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as string | { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
@@ -965,8 +986,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive")
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
@@ -1054,7 +1074,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
@@ -1068,7 +1088,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1077,7 +1097,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||
// recomputed from the merged breakdown so the inclusive total stays
|
||||
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
|
||||
@@ -1095,7 +1115,9 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
[providerMetadataKey]:
|
||||
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
|
||||
{},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1113,7 +1135,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
|
||||
|
||||
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
|
||||
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
|
||||
if (!block.type || !isServerToolResultType(block.type)) return undefined
|
||||
const errorPayload =
|
||||
typeof block.content === "object" && block.content !== null && "type" in block.content
|
||||
@@ -1127,7 +1149,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefine
|
||||
providerExecuted: true,
|
||||
// The complete payload is irreducible provider replay state: subsequent
|
||||
// stateless requests must round-trip the typed result block verbatim.
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1136,8 +1158,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mapUsage(event.message?.usage)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
|
||||
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onContentBlockStart = (
|
||||
@@ -1189,14 +1211,16 @@ const onContentBlockStart = (
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `reasoning-${event.index ?? 0}`
|
||||
const providerMetadata =
|
||||
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||
const metadata =
|
||||
block.signature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: block.thinking
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
|
||||
: lifecycle,
|
||||
reasoningSignatures:
|
||||
event.index === undefined || block.signature === undefined
|
||||
@@ -1219,14 +1243,14 @@ const onContentBlockStart = (
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block)
|
||||
const result = serverToolResultEvent(block, state.providerMetadataKey)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
|
||||
@@ -1306,7 +1330,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
@@ -1318,7 +1342,7 @@ const onMessageDelta = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1331,7 +1355,7 @@ const onMessageDelta = (
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
@@ -1363,14 +1387,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
const onError = (event: AnthropicEvent) => {
|
||||
const message = providerErrorMessage(event)
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
reason: classifyProviderFailure({ message, rawBody: body }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
@@ -1418,9 +1443,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
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.fail(ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`))
|
||||
}
|
||||
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
|
||||
}
|
||||
@@ -1459,7 +1482,8 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
@@ -1473,10 +1497,9 @@ export const route = Route.make({
|
||||
provider: "anthropic",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(
|
||||
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
|
||||
{ baseURL: DEFAULT_BASE_URL },
|
||||
),
|
||||
endpoint: Endpoint.path((input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH), {
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
|
||||
@@ -258,19 +258,21 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ tool: { name } }) as const,
|
||||
})
|
||||
|
||||
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
@@ -318,6 +320,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
@@ -365,8 +368,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const signature = reasoningSignature(part)
|
||||
const redactedData = reasoningRedactedData(part)
|
||||
const signature = reasoningSignature(part, providerMetadataKey)
|
||||
const redactedData = reasoningRedactedData(part, providerMetadataKey)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||
continue
|
||||
@@ -466,7 +469,7 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const inputTokens = ProviderShared.sumTokens(
|
||||
usage.inputTokens,
|
||||
@@ -480,11 +483,12 @@ const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
@@ -541,20 +545,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const providerMetadata = reasoning.signature
|
||||
? bedrockMetadata({ signature: reasoning.signature })
|
||||
const metadata = reasoning.signature
|
||||
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? bedrockMetadata({ redactedData })
|
||||
? providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || providerMetadata !== undefined
|
||||
? Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
reasoning.text ?? "",
|
||||
providerMetadata,
|
||||
)
|
||||
reasoning.text !== undefined || metadata !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
: state.lifecycle
|
||||
return [
|
||||
{
|
||||
@@ -596,7 +594,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
state.reasoningSignatures[index]
|
||||
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
|
||||
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
|
||||
: undefined,
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
@@ -633,7 +631,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -647,13 +645,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.exception) {
|
||||
const message =
|
||||
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error"
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message:
|
||||
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
|
||||
code: event.exception.type,
|
||||
message,
|
||||
rawBody: body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -696,7 +694,8 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: BedrockEvent,
|
||||
initial: () => ({
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, Encoding, Stream } from "effect"
|
||||
import { AIError, AIErrorReason } from "../schema/index.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
@@ -49,10 +50,14 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
`Failed to decode Bedrock Converse event-stream frame: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
Encoding.encodeBase64(view.subarray(0, totalLength)),
|
||||
error,
|
||||
),
|
||||
})
|
||||
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
||||
|
||||
const payload = utf8.decode(decoded.body)
|
||||
const body = ProviderShared.encodeJson({ headers: decoded.headers, body: payload })
|
||||
const messageType = decoded.headers[":message-type"]?.value
|
||||
if (messageType === "error") {
|
||||
const code = decoded.headers[":error-code"]?.value
|
||||
@@ -61,6 +66,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
route,
|
||||
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
|
||||
"Bedrock Converse event-stream error",
|
||||
body,
|
||||
)
|
||||
}
|
||||
const eventType =
|
||||
@@ -70,7 +76,6 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
? decoded.headers[":exception-type"]?.value
|
||||
: undefined
|
||||
if (typeof eventType !== "string") continue
|
||||
const payload = utf8.decode(decoded.body)
|
||||
if (!payload) continue
|
||||
// The AWS event stream pads short payloads with a `p` field. Drop it
|
||||
// before handing the object to the chunk schema. JSON decode goes
|
||||
@@ -80,9 +85,21 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
route,
|
||||
payload,
|
||||
"Failed to parse Bedrock Converse event-stream payload",
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({ ...error.reason, message: error.message, cause: error.reason.cause, body }),
|
||||
}),
|
||||
),
|
||||
)) as Record<string, unknown>
|
||||
delete parsed.p
|
||||
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
|
||||
out.push({
|
||||
...(messageType === "exception"
|
||||
? { exception: { type: eventType, details: parsed } }
|
||||
: { [eventType]: parsed }),
|
||||
rawBody: body,
|
||||
})
|
||||
}
|
||||
return [cursor, out] as const
|
||||
})
|
||||
@@ -95,6 +112,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
*/
|
||||
export const framing = (route: string): Framing.Definition<object> => ({
|
||||
id: "aws-event-stream",
|
||||
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
|
||||
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
@@ -221,6 +223,7 @@ const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
@@ -229,6 +232,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
@@ -285,22 +289,23 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
|
||||
return { inlineData: { mimeType: media.mime, data: media.base64 } }
|
||||
})
|
||||
|
||||
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
|
||||
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
|
||||
? google.thoughtSignature
|
||||
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
|
||||
const value = metadata?.[key]
|
||||
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
|
||||
? value.thoughtSignature
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
@@ -342,15 +347,19 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
parts.push({
|
||||
text: part.text,
|
||||
thought: true,
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -498,7 +507,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
@@ -519,7 +528,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: { google: usage },
|
||||
providerMetadata: providerMetadata(metadataKey, usage),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -567,10 +576,15 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
@@ -579,16 +593,32 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
state.promptFeedback === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
usage: event.usageMetadata
|
||||
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
|
||||
: state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (!candidate?.content)
|
||||
@@ -632,7 +662,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -640,14 +670,16 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
part.text,
|
||||
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textSignature = undefined
|
||||
continue
|
||||
@@ -667,7 +699,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
@@ -675,8 +709,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -714,6 +749,7 @@ export const protocol = Protocol.make({
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: (request) => ({
|
||||
route: `${request.model.provider}/${request.model.route.id}`,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
|
||||
@@ -9,15 +9,7 @@ import {
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
type ProviderMetadata,
|
||||
} from "../schema/index.js"
|
||||
import { AIError, Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -124,13 +116,6 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||
)
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -169,11 +154,9 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Google Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GoogleImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Google Images returned an invalid response", cause)),
|
||||
)
|
||||
const candidates = decoded.candidates ?? []
|
||||
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||
@@ -208,9 +191,10 @@ export const model = (input: ModelInput) => {
|
||||
)
|
||||
const images = yield* Effect.forEach(encoded, (item) =>
|
||||
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||
Effect.mapError(() =>
|
||||
invalidOutput(
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(
|
||||
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.map(
|
||||
@@ -237,16 +221,10 @@ export const model = (input: ModelInput) => {
|
||||
const finishReasons = candidates.flatMap((candidate) =>
|
||||
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||
)
|
||||
return yield* invalidOutput(
|
||||
return yield* output.invalid(
|
||||
`Google Images returned no final images${
|
||||
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||
{
|
||||
google: {
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
}; inspect body for prompt feedback and candidate details`,
|
||||
)
|
||||
}
|
||||
const usage = decoded.usageMetadata
|
||||
@@ -290,12 +268,11 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
|
||||
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||
if (image.type === "url")
|
||||
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||
return ImageInputs.decodeDataUrl(image.url).pipe(
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(
|
||||
ADAPTER,
|
||||
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||
),
|
||||
)
|
||||
@@ -305,7 +282,7 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
|
||||
}),
|
||||
)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
ImageInputs.invalid("Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import type { HttpContext } from "../schema/index.js"
|
||||
import {
|
||||
HttpTransport,
|
||||
WebSocketTransport,
|
||||
@@ -60,8 +61,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError(() =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
)
|
||||
if (terminal)
|
||||
@@ -73,13 +74,13 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
if (event.type === "error") {
|
||||
terminal = true
|
||||
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
|
||||
),
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
|
||||
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
|
||||
}
|
||||
}
|
||||
if (event.type === "response.failed") {
|
||||
@@ -92,7 +93,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
|
||||
error: OpenResponses.providerFailure(event, `${options.name} response failed`, frame),
|
||||
}
|
||||
}
|
||||
if (event.type === "response.created") {
|
||||
@@ -175,23 +176,37 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
channel,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, runtime, executeOptions) => {
|
||||
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: {
|
||||
url: prepared.channel.url,
|
||||
headers: prepared.channel.headers,
|
||||
rotateAfterMs: prepared.channel.rotateAfterMs,
|
||||
},
|
||||
fallback: () =>
|
||||
Stream.unwrap(
|
||||
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
|
||||
),
|
||||
driver: prepared.channel.driver,
|
||||
}
|
||||
return executeOptions.webSocket.execute(exchange)
|
||||
},
|
||||
execute: (prepared, request, runtime, executeOptions) =>
|
||||
Effect.gen(function* () {
|
||||
if (!executeOptions?.webSocket || !prepared.channel) return yield* http.execute(prepared.http, request, runtime)
|
||||
let fallbackHttp: HttpContext | undefined
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: {
|
||||
url: prepared.channel.url,
|
||||
headers: prepared.channel.headers,
|
||||
rotateAfterMs: prepared.channel.rotateAfterMs,
|
||||
},
|
||||
fallback: () =>
|
||||
Stream.unwrap(
|
||||
http.execute(prepared.http, request, runtime).pipe(
|
||||
Effect.map((execution) => {
|
||||
fallbackHttp = execution.http
|
||||
return execution.frames
|
||||
}),
|
||||
),
|
||||
),
|
||||
driver: prepared.channel.driver,
|
||||
}
|
||||
const execution = yield* executeOptions.webSocket.execute(exchange)
|
||||
return {
|
||||
frames: execution.frames,
|
||||
complete: execution.complete,
|
||||
get http() {
|
||||
return fallbackHttp ?? execution.http
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AIError, TransportReason } from "../schema/index.js"
|
||||
import { AIError, TransportError } from "../schema/index.js"
|
||||
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
@@ -99,17 +99,17 @@ const incremental = (
|
||||
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
|
||||
const rejected = (
|
||||
input: DriverInput,
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
recovery: "retry-full" | "rotate-and-retry-full",
|
||||
): ChannelObservation => ({
|
||||
type: "rejected",
|
||||
recovery,
|
||||
error: new AIError({
|
||||
module: input.id,
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: observation.error.message,
|
||||
body: observation.error.reason.body,
|
||||
http: observation.error.reason.http,
|
||||
cause: observation.error.reason.cause,
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
@@ -137,15 +137,16 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
)
|
||||
const observation = yield* input.base.observe(create, frame)
|
||||
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
|
||||
if (observation.type === "provider-failure") {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached")
|
||||
return rejected(input, observation, "rotate-and-retry-full")
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
const responseID = event.response?.id
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
ProviderInternalError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -196,7 +196,7 @@ type LoweredInputItem =
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
type OpenResponsesReasoningInput = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
id?: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
@@ -346,7 +346,8 @@ export const Event = Schema.StructWithRest(
|
||||
item_id: Schema.optional(Schema.String),
|
||||
output_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
|
||||
item: optionalNull(StreamItem),
|
||||
response: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -372,9 +373,6 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
// Which lowered input item a persisted item id is about to be attached to.
|
||||
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -384,10 +382,6 @@ export interface Extension {
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
// Optional grammar check applied before a persisted item id is resent as
|
||||
// part of replayed history. Returning false drops the id; every lowered
|
||||
// item treats a dropped id the same as an absent one.
|
||||
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -447,53 +441,37 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
// Servers validate item ids on replayed history, and a malformed or oversized
|
||||
// id can fail an otherwise valid request. Only server-issued tokens are worth
|
||||
// resending; anything else is treated as absent so the item is resent without
|
||||
// an id (or skipped, for items that cannot be expressed without one).
|
||||
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
||||
// Server-issued item ids need a nonempty prefix and suffix, but the prefix is
|
||||
// provider-defined and does not necessarily identify the item's semantic type.
|
||||
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||
const metadata = providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) &&
|
||||
typeof metadata.itemId === "string" &&
|
||||
ITEM_ID_PATTERN.test(metadata.itemId)
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
|
||||
const separator = metadata.itemId.indexOf("_")
|
||||
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
|
||||
}
|
||||
|
||||
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
|
||||
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesInputItem => {
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
extension: Extension,
|
||||
): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id,
|
||||
...(id === undefined ? {} : { id }),
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
@@ -614,8 +592,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const rawID = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
@@ -640,23 +617,23 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey, extension))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -668,7 +645,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
hostedToolItems.add(id)
|
||||
@@ -836,7 +813,7 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
|
||||
})
|
||||
|
||||
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
|
||||
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
|
||||
item.type === "reasoning" && typeof item.id === "string"
|
||||
|
||||
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
@@ -915,7 +892,7 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id) {
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const phase = messagePhase(item.phase)
|
||||
return [
|
||||
{
|
||||
@@ -946,7 +923,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
const id = item.id ?? item.call_id
|
||||
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
@@ -965,7 +942,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (event.summary_index === 0) return [state, NO_EVENTS]
|
||||
@@ -1012,7 +989,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
return [
|
||||
@@ -1037,7 +1014,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
) {
|
||||
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tool = state.tools[event.item_id]
|
||||
if (!tool) return [state, NO_EVENTS] satisfies StepResult
|
||||
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
|
||||
@@ -1068,7 +1045,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
@@ -1099,7 +1076,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
|
||||
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
@@ -1160,7 +1137,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
!id ||
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]))
|
||||
)
|
||||
@@ -1211,11 +1188,8 @@ const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload |
|
||||
return message || code
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
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 =
|
||||
@@ -1230,25 +1204,19 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
event.response === undefined &&
|
||||
summary === undefined &&
|
||||
status === undefined
|
||||
? new ProviderInternalReason({ message })
|
||||
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason,
|
||||
})
|
||||
? new ProviderInternalError({ message, body })
|
||||
: classifyProviderFailure({ message, status, rawBody: body })
|
||||
return new AIError({ reason })
|
||||
}
|
||||
|
||||
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
|
||||
|
||||
export const step = (state: ParserState, input: Event) => {
|
||||
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
|
||||
const event =
|
||||
input.item_id && outputItemID(state, input) !== input.item_id
|
||||
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
|
||||
? { ...input, item_id: outputItemID(state, input) }
|
||||
: input
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.output_text.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
@@ -1257,7 +1225,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
}
|
||||
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")
|
||||
if (event.item_id === undefined || typeof value !== "string")
|
||||
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
@@ -1266,7 +1234,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
)
|
||||
}
|
||||
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 === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (
|
||||
@@ -1274,24 +1242,24 @@ export const step = (state: ParserState, input: Event) => {
|
||||
event.type === "response.reasoning_summary_text.done" ||
|
||||
event.type === "response.reasoning_text.done"
|
||||
) {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return event.item_id
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
event.output_index !== undefined && id
|
||||
event.output_index !== undefined && id !== undefined
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
|
||||
: state,
|
||||
event,
|
||||
@@ -1299,20 +1267,27 @@ export const step = (state: ParserState, input: Event) => {
|
||||
)
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
|
||||
return event.item_id
|
||||
return event.item_id !== undefined
|
||||
? onFunctionCallArgumentsDelta(state, event)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
state.id,
|
||||
`${state.name} returned a malformed error event`,
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
|
||||
)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
AIErrorReason,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
UnknownProviderReason,
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
@@ -253,6 +254,7 @@ interface PendingToolDelta {
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
@@ -324,17 +326,18 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
|
||||
return typeof field === "string" ? field : undefined
|
||||
}
|
||||
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
|
||||
const observed = parts.flatMap((part) => {
|
||||
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
@@ -366,7 +369,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -388,10 +391,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
}
|
||||
}
|
||||
const text = reasoning.map((part) => part.text).join("")
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
|
||||
const observedField = reasoning
|
||||
.map((part) => reasoningField(part, options.providerMetadataKey))
|
||||
.find((value) => value !== undefined)
|
||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const fullyStructured = reasoning.every((part) =>
|
||||
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
|
||||
)
|
||||
const field = (() => {
|
||||
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
|
||||
return configuredField
|
||||
@@ -459,7 +466,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant")
|
||||
@@ -495,8 +502,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
|
||||
const lowering = {
|
||||
...options,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
toolCallID: (id: string) => {
|
||||
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
|
||||
if (mistral)
|
||||
return id
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(0, 9)
|
||||
.padEnd(9, "0")
|
||||
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
|
||||
return id.slice(0, 40)
|
||||
@@ -505,7 +517,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
}
|
||||
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
|
||||
const bridgeTools = () => {
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
|
||||
messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -557,7 +570,10 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
message.content.every((part) => part.type === "text" && part.text.trim() === "")
|
||||
)
|
||||
continue
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, lowering)
|
||||
@@ -588,7 +604,10 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
|
||||
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
|
||||
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
|
||||
// require `max_tokens`.
|
||||
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
|
||||
const detectMaxTokensField = (
|
||||
provider: string,
|
||||
baseURL: string | undefined,
|
||||
): "max_tokens" | "max_completion_tokens" => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
if (
|
||||
@@ -638,7 +657,8 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
|
||||
const isChutes = p === "chutes" || url.includes("chutes.ai")
|
||||
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
|
||||
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
|
||||
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isVercelAiGateway =
|
||||
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
|
||||
const isOpencode = p === "opencode" || url.includes("opencode.ai")
|
||||
const isNonStandard =
|
||||
@@ -670,11 +690,7 @@ const detectSupportsStrictMode = (provider: string, baseURL: string | undefined)
|
||||
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
|
||||
}
|
||||
|
||||
const detectZaiToolStream = (
|
||||
provider: string,
|
||||
baseURL: string | undefined,
|
||||
modelID: string,
|
||||
): boolean => {
|
||||
const detectZaiToolStream = (provider: string, baseURL: string | undefined, modelID: string): boolean => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
const isZai =
|
||||
@@ -724,10 +740,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
|
||||
const supportsUsageInStreaming =
|
||||
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
|
||||
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
|
||||
const supportsStrictMode =
|
||||
request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
|
||||
const zaiToolStream =
|
||||
request.model.compatibility?.zaiToolStream ??
|
||||
detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = request.tools.length > 0
|
||||
return {
|
||||
@@ -769,26 +785,22 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
// Streaming parsers are small state machines: every event returns a new state
|
||||
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
|
||||
// because OpenAI streams JSON arguments across multiple deltas.
|
||||
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
reason,
|
||||
})
|
||||
|
||||
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
|
||||
switch (reason) {
|
||||
case "error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
|
||||
)
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
case "network_error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
|
||||
)
|
||||
return yield* new AIError({
|
||||
reason: new ProviderInternalError({
|
||||
message: "Provider reported a network error (finish_reason: network_error)",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
case "stop":
|
||||
case "end":
|
||||
return "stop" as const
|
||||
@@ -812,15 +824,14 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
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 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 reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -832,7 +843,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { openai: usage },
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -906,8 +917,12 @@ const conflictingReasoningTextDetails = (previous: Record<string, unknown>, curr
|
||||
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||
|
||||
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||
openai: {
|
||||
const reasoningMetadata = (
|
||||
providerMetadataKey: string,
|
||||
field: ParserState["reasoningField"],
|
||||
details?: ReadonlyArray<unknown>,
|
||||
) => ({
|
||||
[providerMetadataKey]: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
...(details ? { reasoningDetails: details } : {}),
|
||||
},
|
||||
@@ -918,12 +933,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body,
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
@@ -934,15 +945,17 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
// 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 usage =
|
||||
mapUsage(event.usage, state.providerMetadataKey) ??
|
||||
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
|
||||
state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason
|
||||
? {
|
||||
normalized: yield* mapFinishReason(event, rawFinishReason),
|
||||
raw: choice?.native_finish_reason ?? rawFinishReason,
|
||||
}
|
||||
: state.finishReason
|
||||
const finishReason = rawFinishReason
|
||||
? {
|
||||
normalized: yield* mapFinishReason(event, rawFinishReason),
|
||||
raw: choice?.native_finish_reason ?? rawFinishReason,
|
||||
}
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
@@ -973,7 +986,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
@@ -989,7 +1002,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
@@ -999,7 +1016,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
@@ -1038,7 +1059,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result))
|
||||
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
|
||||
return yield* new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...result.reason,
|
||||
message: result.message,
|
||||
cause: result.reason.cause,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
tools = result.tools
|
||||
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(...result.events)
|
||||
@@ -1060,6 +1088,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
return [
|
||||
{
|
||||
providerMetadataKey: state.providerMetadataKey,
|
||||
tools: finished?.tools ?? tools,
|
||||
pendingTools,
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
@@ -1081,11 +1110,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
|
||||
if (state.finishReason === undefined && state.requireFinishReason)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
classification: "incomplete-stream",
|
||||
route: ADAPTER,
|
||||
}),
|
||||
})
|
||||
@@ -1103,12 +1130,18 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||
? Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
|
||||
)
|
||||
: state.lifecycle
|
||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
@@ -1135,6 +1168,7 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingTools: {},
|
||||
toolCallEvents: [],
|
||||
|
||||
@@ -9,14 +9,7 @@ import {
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -84,13 +77,6 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -104,12 +90,12 @@ export const model = (input: ModelInput) => {
|
||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||
const mask = request.options?.mask
|
||||
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||
return yield* ImageInputs.invalid("An OpenAI image mask requires at least one input image")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const sourceImages = request.images ?? []
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url)
|
||||
return Effect.undefined
|
||||
})
|
||||
const multipartMask =
|
||||
@@ -118,7 +104,7 @@ export const model = (input: ModelInput) => {
|
||||
: mask.type === "bytes"
|
||||
? { data: mask.data, mediaType: mask.mediaType }
|
||||
: mask.type === "url"
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url)
|
||||
: undefined
|
||||
const useMultipart =
|
||||
sourceImages.length > 0 &&
|
||||
@@ -161,7 +147,7 @@ export const model = (input: ModelInput) => {
|
||||
return undefined
|
||||
})
|
||||
if (references.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const maskReference =
|
||||
mask === undefined
|
||||
? undefined
|
||||
@@ -173,7 +159,7 @@ export const model = (input: ModelInput) => {
|
||||
? { file_id: mask.id }
|
||||
: undefined
|
||||
if (mask !== undefined && maskReference === undefined)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
@@ -209,11 +195,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
options: OpenAIImageOptions | undefined,
|
||||
overlay: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "OpenAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("OpenAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||
const format =
|
||||
@@ -221,7 +205,7 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.mapError((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
@@ -241,9 +225,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||
if (images.length === 0) return yield* output.invalid("OpenAI Images returned no images")
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
|
||||
@@ -86,28 +86,10 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
// Replayed items are paired with stored server state by id, so a foreign or
|
||||
// synthetic token can fail request validation even when `call_id` pairing is
|
||||
// intact. Only resend ids in each item kind's own grammar; hosted tool
|
||||
// items keep generic validation because every hosted tool mints its own
|
||||
// prefix. The same allowlist approach codex uses before resending history
|
||||
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
|
||||
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
|
||||
message: ["msg_"],
|
||||
reasoning: ["rs_"],
|
||||
"function-call": ["fc_"],
|
||||
// Every hosted tool mints its own id prefix, so items keep generic validation.
|
||||
"hosted-tool": [],
|
||||
}
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
|
||||
const prefixes = ITEM_ID_PREFIXES[kind]
|
||||
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -167,7 +149,9 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64", undefined, cause),
|
||||
),
|
||||
)
|
||||
const format = item.output_format ?? "png"
|
||||
return {
|
||||
@@ -202,7 +186,7 @@ const HOSTED_TOOLS = {
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
|
||||
)
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
HttpContext,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -96,17 +97,15 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
reason: new InvalidProviderOutputError({ route, message, body, cause }),
|
||||
})
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
try: () => decodeJson(input),
|
||||
catch: () => eventError(route, message, input),
|
||||
catch: (cause) => eventError(route, message, input, cause),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -233,7 +232,7 @@ export const sseFraming = (
|
||||
(state, chunk) =>
|
||||
Effect.gen(function* () {
|
||||
const error = state.parser.feed(chunk)
|
||||
if (error) return yield* eventError("sse", error.message)
|
||||
if (error) return yield* eventError("sse", error.message, chunk, error)
|
||||
return [state, state.output.splice(0)] as const
|
||||
}),
|
||||
),
|
||||
@@ -249,13 +248,39 @@ export const sseFraming = (
|
||||
/**
|
||||
* Canonical invalid-request constructor shared by protocol lowering.
|
||||
*/
|
||||
export const invalidRequest = (message: string) =>
|
||||
export const invalidRequest = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
reason: new InvalidRequestError({ message, cause }),
|
||||
})
|
||||
|
||||
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
|
||||
route: string,
|
||||
name: string,
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const http = new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
|
||||
const body = yield* response.text.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route,
|
||||
message: `Failed to read the ${name} response`,
|
||||
http,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {
|
||||
body,
|
||||
invalid: (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({ route, message, body, http, cause }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
toolChoice: NonNullable<LLMRequest["toolChoice"]>,
|
||||
@@ -302,7 +327,7 @@ export const unsupportedContent = (
|
||||
export const validateWith =
|
||||
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
|
||||
(payload: I) =>
|
||||
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
|
||||
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message, error)))
|
||||
|
||||
/**
|
||||
* Build an HTTP POST with a JSON body. Sets `content-type: application/json`
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Effect, Encoding } from "effect"
|
||||
import type { ImageInput } from "../../image.js"
|
||||
import { InvalidRequestReason, AIError } from "../../schema/index.js"
|
||||
import { InvalidRequestError, AIError } from "../../schema/index.js"
|
||||
|
||||
const invalid = (module: string, message: string) =>
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module,
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
reason: new InvalidRequestError({ message, cause }),
|
||||
})
|
||||
|
||||
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||
@@ -14,13 +12,12 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
|
||||
|
||||
export const decodeDataUrl = (
|
||||
url: string,
|
||||
module: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||
if (!url.startsWith("data:")) return Effect.undefined
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||
if (!match) return Effect.fail(invalid("Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||
Effect.mapError((cause) => invalid("Image data URL contains invalid base64 data", cause)),
|
||||
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,9 @@ export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
export const ServiceTier = Schema.declare<ServiceTier>((value): value is ServiceTier => typeof value === "string", {
|
||||
title: "ServiceTier",
|
||||
})
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
|
||||
@@ -34,37 +34,35 @@ 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
|
||||
})
|
||||
) => 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,14 +2,7 @@ import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -94,13 +87,6 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -120,7 +106,7 @@ export const model = (input: ModelInput) => {
|
||||
return undefined
|
||||
})
|
||||
if (imageReferences.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
return yield* ImageInputs.invalid("xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
@@ -149,17 +135,17 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "xAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(XAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("xAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(`xAI Images result ${index} contains invalid base64 data`, cause),
|
||||
),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
@@ -183,9 +169,9 @@ export const model = (input: ModelInput) => {
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
return Effect.fail(output.invalid(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||
if (images.length === 0) return yield* output.invalid("xAI Images returned no images")
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
return new ImageResponse({
|
||||
images,
|
||||
|
||||
@@ -2,13 +2,7 @@ import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -63,13 +57,6 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -82,7 +69,7 @@ export const model = (input: ModelInput) => {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||
if ((request.images?.length ?? 0) > 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||
return yield* ImageInputs.invalid("Z.ai hosted image generation does not support image inputs")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{ model: request.model.id, prompt: request.prompt },
|
||||
@@ -104,13 +91,11 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Z.ai Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ZAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Z.ai Images returned an invalid response", cause)),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||
)
|
||||
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||
if (decoded.data.length === 0) return yield* output.invalid("Z.ai Images returned no images")
|
||||
return new ImageResponse({
|
||||
images: decoded.data.map(
|
||||
(item) =>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidRequestReason,
|
||||
AuthenticationError,
|
||||
ContentPolicyError,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ProviderErrorEvent,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
UnknownProviderReason,
|
||||
ProviderInternalError,
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
UnknownProviderError,
|
||||
type HttpContext,
|
||||
type HttpRateLimitDetails,
|
||||
type ProviderMetadata,
|
||||
} from "./schema/index.js"
|
||||
|
||||
const patterns = [
|
||||
@@ -40,15 +39,16 @@ const patterns = [
|
||||
/model_context_window_exceeded/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
/request_too_large/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
@@ -79,91 +79,96 @@ const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | 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
|
||||
// Some SDKs supply parsed error data separately from the original response text.
|
||||
readonly data?: unknown
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly cause?: unknown
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
|
||||
const body = input.rawBody ?? ""
|
||||
const codes = [...providerCodes(input.data), ...providerCodes(body), ...providerCodes(input.message)].map((code) =>
|
||||
code.toLowerCase(),
|
||||
)
|
||||
// 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 clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
if (
|
||||
clientScoped &&
|
||||
(codes.includes("context_length_exceeded") ||
|
||||
codes.includes("model_context_window_exceeded") ||
|
||||
codes.includes("request_too_large") ||
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
return new InvalidRequestError({ ...details, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (codes.includes("permission_error"))
|
||||
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (
|
||||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
|
||||
)
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
return new InvalidRequestError(details)
|
||||
return new UnknownProviderError(details)
|
||||
}
|
||||
|
||||
function providerCodes(value: string) {
|
||||
const decoded = Option.getOrUndefined(decodeJson(value))
|
||||
function providerCodes(value: unknown) {
|
||||
const decoded = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!isRecord(decoded)) return []
|
||||
const error = isRecord(decoded.error) ? decoded.error : undefined
|
||||
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
|
||||
const response = isRecord(decoded.response) ? decoded.response : undefined
|
||||
const responseError = response && isRecord(response.error) ? response.error : undefined
|
||||
const exception = isRecord(decoded.exception) ? decoded.exception : undefined
|
||||
return [decoded.code, error?.code, error?.type, error?.status, responseError?.code, exception?.type].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
@@ -23,22 +23,30 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "bedrock-mantle-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
return route.with({
|
||||
@@ -70,7 +78,7 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
return {
|
||||
id,
|
||||
model: chat,
|
||||
model: responses,
|
||||
chat,
|
||||
responses,
|
||||
configure,
|
||||
@@ -88,6 +96,7 @@ const config = (settings: Settings): Config => {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
@@ -103,4 +112,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const model = chatModel
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -35,6 +35,7 @@ const configuredRoute = (input: Config) => {
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
})
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { chatModel as model } from "../amazon-bedrock-mantle.js"
|
||||
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
|
||||
export type { Settings } from "../amazon-bedrock-mantle.js"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepinfra")
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
id: "deepinfra-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.deepinfra.baseURL },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const root = baseURL?.replace(/\/+$/, "")
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: {
|
||||
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
},
|
||||
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerOptions: { store: false },
|
||||
})
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const protocol = {
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "google",
|
||||
providerMetadataKey: "vertex",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("groq")
|
||||
|
||||
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
|
||||
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
|
||||
readonly includeReasoning?: boolean
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly serviceTier?: "on_demand" | "flex" | "auto" | "performance" | (string & {})
|
||||
readonly user?: string
|
||||
}
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
includeReasoning: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "groq-chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
reasoning_format: Schema.optional(Schema.Literal("parsed")),
|
||||
include_reasoning: Schema.optional(Schema.Boolean),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
}),
|
||||
from: Effect.fn("Groq.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
const gptOSS = request.model.id.startsWith("openai/gpt-oss-")
|
||||
return {
|
||||
...(yield* OpenAIChat.fromRequest(request)),
|
||||
reasoning_format: gptOSS ? undefined : ("parsed" as const),
|
||||
include_reasoning: gptOSS ? options.includeReasoning : undefined,
|
||||
parallel_tool_calls: options.parallelToolCalls,
|
||||
service_tier: options.serviceTier,
|
||||
user: options.user,
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: "groq-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
|
||||
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<ProviderOptions>({
|
||||
id: modelID,
|
||||
compatibility: {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
reasoningField: "reasoning",
|
||||
requireReasoning: false,
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Groq from "./groq.js"
|
||||
@@ -6,11 +6,13 @@ export * as Azure from "./azure.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cloudflare from "./cloudflare.js"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as Google from "./google.js"
|
||||
export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
@@ -164,6 +164,7 @@ const bodyOptions = (input: unknown) => {
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: profile.provider,
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
framing: Framing.sse,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema/index.js"
|
||||
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -137,12 +137,10 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
const toAIError = (error: AuthError): AIError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new AIError({
|
||||
module: "Auth",
|
||||
method: "apply",
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
||||
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
return error
|
||||
|
||||
@@ -12,13 +12,14 @@ import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidProviderOutputError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -89,6 +90,7 @@ export interface RouteDefaultsInput {
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
readonly auth?: Auth.Definition
|
||||
readonly transport?: Transport<Body, Prepared, unknown>
|
||||
readonly endpoint?: EndpointPatch<Body>
|
||||
@@ -226,16 +228,14 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed instanceof AIError) return failed
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
return ProviderShared.eventError(route, message, undefined, cause)
|
||||
}
|
||||
|
||||
const incompleteStreamError = (route: string) =>
|
||||
new AIError({
|
||||
module: "LLMClient",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended unexpectedly.",
|
||||
classification: "incomplete-stream",
|
||||
route,
|
||||
}),
|
||||
})
|
||||
@@ -264,11 +264,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
|
||||
const decodeEvent = (route: string) => (frame: Frame) =>
|
||||
decodeEventEffect(frame).pipe(
|
||||
Effect.mapError(() =>
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
input.id,
|
||||
`Invalid ${route} stream event`,
|
||||
typeof frame === "string" ? frame : ProviderShared.encodeJson(frame),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -289,11 +290,16 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { id, provider, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
providerMetadataKey ??
|
||||
(provider !== undefined && String(provider) !== String(routeInput.provider)
|
||||
? String(provider)
|
||||
: routeInput.providerMetadataKey),
|
||||
auth: auth ?? routeInput.auth,
|
||||
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
|
||||
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
|
||||
@@ -318,19 +324,48 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return Stream.unwrap(
|
||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||
Effect.map((execution) => {
|
||||
const terminal = protocol.stream.terminal
|
||||
// Preserve assembled inputs; replace only serialized event fallbacks with their original wire data.
|
||||
const frameError =
|
||||
(frame: Frame, event: Frame | Event = frame) =>
|
||||
(error: AIError) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
body:
|
||||
error.reason.body !== undefined && error.reason.body !== ProviderShared.encodeJson(event)
|
||||
? error.reason.body
|
||||
: (execution.body?.(frame) ??
|
||||
(typeof frame === "string" ? frame : ProviderShared.encodeJson(frame))),
|
||||
}),
|
||||
})
|
||||
const events = execution.frames.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
Stream.mapEffect((frame) =>
|
||||
decodeEvent(route)(frame).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(streamError(route, `Failed to decode ${route} event`, cause)),
|
||||
),
|
||||
Effect.map((event) => ({ event, frame })),
|
||||
Effect.mapError(frameError(frame)),
|
||||
),
|
||||
),
|
||||
terminal ? Stream.takeUntil(({ event }) => terminal(event)) : (stream) => stream,
|
||||
)
|
||||
const stream = Stream.suspend(() => {
|
||||
let state = protocol.stream.initial(request)
|
||||
const parsed = events.pipe(
|
||||
Stream.mapEffect((event) =>
|
||||
Stream.mapEffect(({ event, frame }) =>
|
||||
protocol.stream.step(state, event).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(streamError(route, `Failed to parse ${route} event`, cause)),
|
||||
),
|
||||
Effect.map(([next, output]) => {
|
||||
state = next
|
||||
return output
|
||||
}),
|
||||
Effect.mapError(frameError(frame, event)),
|
||||
),
|
||||
),
|
||||
Stream.flatMap(Stream.fromIterable),
|
||||
@@ -339,15 +374,24 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return onHalt
|
||||
? parsed.pipe(
|
||||
Stream.concat(
|
||||
Stream.suspend(() =>
|
||||
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
|
||||
),
|
||||
Stream.suspend(() => Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable)))),
|
||||
),
|
||||
)
|
||||
: parsed
|
||||
}).pipe(
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
Stream.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? execution.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||
}),
|
||||
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import {
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
AIError,
|
||||
TransportReason,
|
||||
} from "../schema/index.js"
|
||||
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -40,17 +33,6 @@ const headerDetails = (headers: Headers.Headers) =>
|
||||
const normalizedHeaders = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
||||
|
||||
const requestId = (headers: Record<string, string>) => {
|
||||
return (
|
||||
headers["x-request-id"] ??
|
||||
headers["request-id"] ??
|
||||
headers["x-amzn-requestid"] ??
|
||||
headers["x-amz-request-id"] ??
|
||||
headers["x-goog-request-id"] ??
|
||||
headers["cf-ray"]
|
||||
)
|
||||
}
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -108,24 +90,13 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
|
||||
})
|
||||
}
|
||||
|
||||
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
new HttpRequestDetails({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: headerDetails(request.headers),
|
||||
})
|
||||
|
||||
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpResponseDetails({
|
||||
export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpContext({
|
||||
url: response.request.url,
|
||||
status: response.status,
|
||||
headers: headerDetails(response.headers),
|
||||
})
|
||||
|
||||
const responseBody = (body: string | void) => {
|
||||
if (body === undefined) return {}
|
||||
return { body }
|
||||
}
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
@@ -143,81 +114,46 @@ const providerMessage = (status: number, body: string | void) => {
|
||||
)
|
||||
}
|
||||
|
||||
const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
...input.body,
|
||||
requestId: input.requestId,
|
||||
rateLimit: input.rateLimit,
|
||||
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const result = yield* response.text.pipe(Effect.result)
|
||||
return yield* httpFailure({
|
||||
message: providerMessage(response.status, result._tag === "Success" ? result.success : undefined),
|
||||
url: response.request.url,
|
||||
status: response.status,
|
||||
responseHeaders: headerDetails(response.headers),
|
||||
responseBody: result._tag === "Success" ? result.success : undefined,
|
||||
cause: result._tag === "Failure" ? (result.failure.cause ?? result.failure) : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const statusError =
|
||||
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body)
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: classifyProviderFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, body),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty.
|
||||
export const classifyHttpFailure = (input: {
|
||||
/** Preserve HTTP diagnostics for executor and externally captured failures alike. */
|
||||
export const httpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly url?: string | undefined
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly data?: unknown
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
readonly cause?: unknown
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody)
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
return new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
data: input.data,
|
||||
rawBody: input.responseBody,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
cause: input.cause,
|
||||
http:
|
||||
input.status === undefined || input.url === undefined
|
||||
? undefined
|
||||
: new HttpContext({ url: input.url, status: input.status, headers }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -244,25 +180,25 @@ const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly operation: HttpOperation
|
||||
readonly http?: HttpContext
|
||||
}) => {
|
||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||
new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: input.operation,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: failure.message,
|
||||
cause: source,
|
||||
http: input.http,
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: request.url,
|
||||
http: new HttpContext({ request: requestDetails(request) }),
|
||||
}),
|
||||
})
|
||||
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? input.error.reason.cause
|
||||
? (input.error.reason.cause ?? input.error)
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
@@ -286,6 +222,13 @@ const httpError = (input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const responseStream = (response: HttpClientResponse.HttpClientResponse): Stream.Stream<Uint8Array, AIError> =>
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
httpError({ error, request: response.request, operation: "read", http: responseHttp(response) }),
|
||||
),
|
||||
)
|
||||
|
||||
export const stream = (
|
||||
executor: Interface,
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
@@ -294,9 +237,7 @@ export const stream = (
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const response = yield* executor.execute(request, middleware)
|
||||
return response.stream.pipe(
|
||||
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
|
||||
)
|
||||
return responseStream(response)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -309,7 +250,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
if (!middleware)
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
|
||||
Effect.flatMap(statusError(request)),
|
||||
Effect.flatMap(statusError),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
@@ -317,7 +258,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
|
||||
return yield* statusError(response.request)(response)
|
||||
return yield* statusError(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { AIError } from "../schema/index.js"
|
||||
export interface Definition<Frame> {
|
||||
readonly id: string
|
||||
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
|
||||
/** Original wire representation when framing transforms the provider payload. */
|
||||
readonly body?: (frame: Frame) => string | undefined
|
||||
}
|
||||
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
|
||||
@@ -88,8 +88,13 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
}
|
||||
}),
|
||||
execute: (prepared, _request, runtime) =>
|
||||
Effect.succeed({
|
||||
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
Effect.gen(function* () {
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
|
||||
return {
|
||||
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
body: prepared.framing.body,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Endpoint } from "../endpoint.js"
|
||||
import { Auth } from "../auth.js"
|
||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
|
||||
import type { WebSocketChannelExecutor } from "./websocket-channel.js"
|
||||
import type { AIError, LLMRequest } from "../../schema/index.js"
|
||||
import type { AIError, HttpContext, LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
@@ -11,6 +11,8 @@ export interface TransportRuntime {
|
||||
|
||||
export interface TransportExecution<Frame> {
|
||||
readonly frames: Stream.Stream<Frame, AIError>
|
||||
readonly http?: HttpContext
|
||||
body?(frame: Frame): string | undefined
|
||||
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
|
||||
readonly complete?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Effect, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import type { AIError } from "../../schema/index.js"
|
||||
import type { AIError, HttpContext } from "../../schema/index.js"
|
||||
|
||||
export interface WebSocketChannelExecutor {
|
||||
readonly execute: (
|
||||
@@ -10,6 +10,7 @@ export interface WebSocketChannelExecutor {
|
||||
|
||||
export interface WebSocketChannelExecution {
|
||||
readonly frames: Stream.Stream<string, AIError>
|
||||
readonly http?: HttpContext
|
||||
/** Commits staged state after the decoded Route stream ends successfully. */
|
||||
readonly complete: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Cause, Effect, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
TransportError,
|
||||
type HttpContext,
|
||||
type TransportOperation,
|
||||
} from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
import type {
|
||||
@@ -17,6 +23,7 @@ export interface WebSocketRequest {
|
||||
}
|
||||
|
||||
export interface WebSocketConnection {
|
||||
readonly http?: HttpContext
|
||||
readonly sendText: (message: string) => Effect.Effect<void, AIError>
|
||||
readonly messages: Stream.Stream<string | Uint8Array, AIError>
|
||||
readonly close: Effect.Effect<void, never>
|
||||
@@ -33,21 +40,22 @@ type WebSocketConstructorWithHeaders = (
|
||||
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
readonly phase?: TransportError["phase"]
|
||||
readonly delivery?: TransportError["delivery"]
|
||||
readonly body?: string
|
||||
readonly cause?: unknown
|
||||
},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketConnector",
|
||||
method,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message,
|
||||
body: input.body,
|
||||
cause: input.cause,
|
||||
transport: "websocket",
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
@@ -59,22 +67,16 @@ const transportError = (
|
||||
|
||||
const annotateTransportError = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
input: { readonly phase: TransportError["phase"]; readonly delivery: TransportError["delivery"] },
|
||||
) =>
|
||||
error.reason._tag === "Transport"
|
||||
? new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
transport: error.reason.transport,
|
||||
operation: error.reason.operation,
|
||||
code: error.reason.code,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
cause: error.reason.cause,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
recovery: error.reason.recovery,
|
||||
}),
|
||||
})
|
||||
: error
|
||||
@@ -95,7 +97,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void
|
||||
if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) {
|
||||
return Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
transportError(`WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
code: "closed",
|
||||
@@ -124,7 +126,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
transportError(`Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
cause: "error" in event ? (event.error ?? event) : event,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
@@ -137,7 +140,9 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||
transportError(`WebSocket closed before opening with code ${event.code}`, {
|
||||
body: event.reason,
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
code: String(event.code),
|
||||
@@ -169,7 +174,8 @@ export const toWebSocketUrl = (value: string) =>
|
||||
throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`)
|
||||
},
|
||||
catch: (error) =>
|
||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
transportError(error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
cause: error,
|
||||
url: value,
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
@@ -189,7 +195,8 @@ export const open = (input: WebSocketRequest) =>
|
||||
headers: input.headers,
|
||||
}),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
transportError(error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
cause: error,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
@@ -214,7 +221,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
|
||||
transportError("WebSocket message exceeds the 16 MiB limit", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message-too-large",
|
||||
@@ -231,7 +239,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket inbound queue overflow", {
|
||||
transportError("WebSocket inbound queue overflow", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
@@ -248,7 +257,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
transportError("Unsupported WebSocket message payload", {
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
@@ -261,7 +271,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
transportError(`WebSocket error: ${eventMessage(event)}`, {
|
||||
cause: "error" in event ? (event.error ?? event) : event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
@@ -274,7 +285,9 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||
transportError(`WebSocket closed with code ${event.code}`, {
|
||||
body: event.reason,
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
@@ -298,7 +311,7 @@ export const fromWebSocket = (
|
||||
Effect.suspend(() => {
|
||||
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||
return Effect.fail(
|
||||
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||
transportError(`WebSocket is not open (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
@@ -308,7 +321,8 @@ export const fromWebSocket = (
|
||||
return Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
transportError(error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
cause: error,
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
@@ -349,10 +363,23 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
|
||||
(connection) => connection.close,
|
||||
)
|
||||
const create = yield* exchange.driver.create(undefined)
|
||||
yield* connection.sendText(create.message)
|
||||
yield* connection.sendText(create.message).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? connection.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const decoder = new TextDecoder()
|
||||
let observed = false
|
||||
return {
|
||||
http: connection.http,
|
||||
frames: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
observed = true
|
||||
@@ -364,9 +391,49 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
|
||||
delivery: observed ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.mapEffect((frame) =>
|
||||
exchange.driver.observe(create, frame).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
body: frame,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Effect.map((observation) =>
|
||||
"error" in observation
|
||||
? {
|
||||
...observation,
|
||||
error: new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...observation.error.reason,
|
||||
message: observation.error.reason.message,
|
||||
cause: observation.error.reason.cause,
|
||||
body: frame,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
: observation,
|
||||
),
|
||||
),
|
||||
),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
Stream.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? connection.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
@@ -417,7 +484,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
const webSocket = options?.webSocket
|
||||
if (!webSocket) {
|
||||
return Effect.fail(
|
||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
transportError("WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
@@ -435,7 +502,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
connect: { url: prepared.url, headers: prepared.headers },
|
||||
fallback: () =>
|
||||
Stream.fail(
|
||||
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||
transportError("WebSocket JSON transport does not provide HTTP fallback", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "websocket",
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, RouteID } from "./ids.js"
|
||||
import { ProviderMetadata } from "./messages.js"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
|
||||
method: Schema.String,
|
||||
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}) {}
|
||||
|
||||
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
|
||||
status: Schema.Number,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -24,74 +18,61 @@ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI
|
||||
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
request: HttpRequestDetails,
|
||||
response: Schema.optional(HttpResponseDetails),
|
||||
body: Schema.optional(Schema.String),
|
||||
bodyTruncated: Schema.optional(Schema.Boolean),
|
||||
requestId: Schema.optional(Schema.String),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
|
||||
_tag: Schema.tag("InvalidRequest"),
|
||||
const ReasonFields = {
|
||||
message: Schema.String,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
// Preserve the complete original response or triggering event before decoding narrows it.
|
||||
body: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
cause: Schema.optional(Schema.Defect({ includeStack: true })),
|
||||
}
|
||||
|
||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
|
||||
_tag: Schema.tag("NoRoute"),
|
||||
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>("AI.Error.InvalidRequest")(
|
||||
"InvalidRequest",
|
||||
{
|
||||
...ReasonFields,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
get message() {
|
||||
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
|
||||
"Authentication",
|
||||
{
|
||||
...ReasonFields,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
|
||||
...ReasonFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
|
||||
"QuotaExceeded",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
|
||||
"ContentPolicy",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class ProviderInternalError extends Schema.TaggedError<ProviderInternalError>("AI.Error.ProviderInternal")(
|
||||
"ProviderInternal",
|
||||
{
|
||||
...ReasonFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
},
|
||||
) {}
|
||||
|
||||
export const TransportType = Schema.Literals(["http", "websocket"])
|
||||
export type TransportType = typeof TransportType.Type
|
||||
@@ -99,14 +80,12 @@ export type TransportType = typeof TransportType.Type
|
||||
export const TransportOperation = Schema.Literals(["request", "read", "write"])
|
||||
export type TransportOperation = typeof TransportOperation.Type
|
||||
|
||||
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
message: Schema.String,
|
||||
export class TransportError extends Schema.TaggedError<TransportError>("AI.Error.Transport")("Transport", {
|
||||
...ReasonFields,
|
||||
transport: TransportType,
|
||||
operation: TransportOperation,
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
phase: Schema.optional(
|
||||
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||
),
|
||||
@@ -116,51 +95,40 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
||||
),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
export class InvalidProviderOutputError extends Schema.TaggedError<InvalidProviderOutputError>(
|
||||
"AI.Error.InvalidProviderOutput",
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
)("InvalidProviderOutput", {
|
||||
...ReasonFields,
|
||||
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class UnknownProviderError extends Schema.TaggedError<UnknownProviderError>("AI.Error.UnknownProvider")(
|
||||
"UnknownProvider",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export const AIErrorReason = Schema.Union([
|
||||
InvalidRequestReason,
|
||||
NoRouteReason,
|
||||
AuthenticationReason,
|
||||
RateLimitReason,
|
||||
QuotaExceededReason,
|
||||
ContentPolicyReason,
|
||||
ProviderInternalReason,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
UnknownProviderReason,
|
||||
InvalidRequestError,
|
||||
NoRouteError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
QuotaExceededError,
|
||||
ContentPolicyError,
|
||||
ProviderInternalError,
|
||||
TransportError,
|
||||
InvalidProviderOutputError,
|
||||
UnknownProviderError,
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||
|
||||
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
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 get message() {
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
override get message(): string {
|
||||
return this.reason.message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError } from "../src/index.js"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
@@ -62,22 +62,85 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
expect(error.reason).toBeInstanceOf(Error)
|
||||
expect(error.cause).toBe(error.reason)
|
||||
return error
|
||||
}
|
||||
|
||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("preserves externally captured HTTP errors without inventing response context", () =>
|
||||
Effect.sync(() => {
|
||||
const cause = new Error("upstream request failed")
|
||||
const body = '{"error":{"message":"Rate limited","trace":"original"}}'
|
||||
const error = RequestExecutor.httpFailure({
|
||||
message: "Rate limited",
|
||||
url: request.url,
|
||||
status: 429,
|
||||
responseHeaders: { "Retry-After": "2", "X-Request-ID": "req_external" },
|
||||
responseBody: body,
|
||||
cause,
|
||||
})
|
||||
|
||||
expect(error.message).toBe("Rate limited")
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 2000 })
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toEqual(
|
||||
new HttpContext({
|
||||
url: request.url,
|
||||
status: 429,
|
||||
headers: { "retry-after": "2", "x-request-id": "req_external" },
|
||||
}),
|
||||
)
|
||||
expect(RequestExecutor.httpFailure({ message: "No response", url: request.url }).reason.http).toBeUndefined()
|
||||
expect(RequestExecutor.httpFailure({ message: "No URL", status: 500 }).reason.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the original body-read failure on an HTTP status error", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("response body disconnected")
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
expect(error.reason.http).toMatchObject({ status: 503, headers: { "x-request-id": "req_failed_body" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses response body failures at the executor seam", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: disconnected query-secret-123 header-secret-456")
|
||||
expect(error.reason.http).toMatchObject({ status: 200, url: secretRequest.url })
|
||||
expect(error.reason.cause).toMatchObject({ code: "ECONNRESET" })
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -104,9 +167,10 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: socket closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
})
|
||||
@@ -133,7 +197,9 @@ describe("RequestExecutor", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason.message).toBe("plugin rejected request")
|
||||
expect(error.message).toBe("plugin rejected request")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
)
|
||||
|
||||
@@ -152,16 +218,11 @@ describe("RequestExecutor", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: proxy disconnected proxy-secret")
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: proxy disconnected proxy-secret",
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
http: {
|
||||
request: {
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
headers: { authorization: "Bearer proxy-secret" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
@@ -206,11 +267,33 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
it.effect("classifies Anthropic request_too_large as context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify ordinary invalid requests as context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
@@ -219,7 +302,7 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -229,9 +312,9 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
|
||||
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toBe(largeProviderMessage)
|
||||
expect(error.reason.body).toContain(largeProviderMessage)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -255,8 +338,8 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "Provider request failed with HTTP 400",
|
||||
})
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -299,30 +382,21 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error).toMatchObject({
|
||||
reason: {
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "secret",
|
||||
},
|
||||
},
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "secret",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(errorHttp(error)?.body).toBe("rate limited")
|
||||
expect(error.reason.body).toBe("rate limited")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -341,8 +415,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
||||
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
||||
@@ -388,8 +461,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(errorHttp(error)?.rateLimit).toEqual({
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "100", "input-tokens": "10000" },
|
||||
remaining: { requests: "12", "input-tokens": "9000" },
|
||||
@@ -398,8 +471,8 @@ describe("RequestExecutor", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("overloaded", {
|
||||
status: 529,
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
@@ -431,7 +504,8 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.http?.status).toBe(503)
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -444,7 +518,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.http?.status).toBe(status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -468,8 +543,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
||||
expect(error.reason.body).toHaveLength(20_000)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -486,9 +560,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe(
|
||||
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
||||
)
|
||||
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -506,7 +578,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -543,6 +615,9 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.reason.body).toBe("not-json")
|
||||
expect(error.reason.cause).toBeDefined()
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "content-type": "text/event-stream" } })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -556,6 +631,154 @@ describe("WebSocket channel execution", () => {
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
it.effect("preserves close reasons and native event causes without fabricated HTTP metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
class TestSocket extends EventTarget {
|
||||
readyState = globalThis.WebSocket.OPEN
|
||||
send() {}
|
||||
close() {}
|
||||
}
|
||||
const socket = new TestSocket()
|
||||
const connection = yield* WebSocketTransport.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
socket as unknown as globalThis.WebSocket,
|
||||
{ url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
)
|
||||
const event = new CloseEvent("close", { code: 1011, reason: "upstream trace: req_close" })
|
||||
socket.dispatchEvent(event)
|
||||
const error = yield* connection.messages.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", code: "1011", phase: "close" })
|
||||
expect(error.message).toBe("WebSocket closed with code 1011")
|
||||
expect(error.reason.body).toBe(event.reason)
|
||||
expect(error.reason.cause).toBe(event)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
yield* connection.close
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves opening event errors and native send exceptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("native send failed")
|
||||
class TestSocket extends EventTarget {
|
||||
readyState = globalThis.WebSocket.CONNECTING
|
||||
send() {
|
||||
throw cause
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
const socket = new TestSocket()
|
||||
const open = WebSocketTransport.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
socket as unknown as globalThis.WebSocket,
|
||||
{ url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
)
|
||||
const fiber = yield* open.pipe(Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
const event = new ErrorEvent("error", { message: "handshake rejected", error: cause })
|
||||
socket.dispatchEvent(event)
|
||||
const error = yield* Fiber.join(fiber)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.message).toContain("handshake rejected")
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
|
||||
socket.readyState = globalThis.WebSocket.OPEN
|
||||
const connection = yield* open
|
||||
const sent = yield* connection.sendText("create").pipe(Effect.flip)
|
||||
expect(sent.reason.cause).toBe(cause)
|
||||
expect(sent.message).toBe(cause.message)
|
||||
yield* connection.close
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves raw driver failures and known upgrade metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("driver validation failed")
|
||||
const frame = '{ "error": "failed", "trace": "original" }'
|
||||
const http = new HttpContext({
|
||||
url: "https://provider.test/responses",
|
||||
status: 101,
|
||||
headers: { upgrade: "websocket" },
|
||||
})
|
||||
const executor = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
http,
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.make(frame),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const execution = yield* executor.execute({
|
||||
id: "exchange_error",
|
||||
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
fallback: () => Stream.empty,
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||
observe: () =>
|
||||
Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Driver failed",
|
||||
cause,
|
||||
body: "narrowed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Driver failed")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toBe(http)
|
||||
expect(execution.http).toBe(http)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains diagnostic fields when annotating transport delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("connection closed")
|
||||
const executor = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fail(
|
||||
new AIError({
|
||||
reason: new TransportError({
|
||||
message: "Socket closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "close",
|
||||
recovery: "retry-full",
|
||||
body: "server close detail",
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const execution = yield* executor.execute({
|
||||
id: "exchange_closed",
|
||||
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
fallback: () => Stream.empty,
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||
},
|
||||
})
|
||||
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Socket closed")
|
||||
expect(error.reason.body).toBe("server close detail")
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason).toMatchObject({ phase: "close", delivery: "ambiguous", recovery: "retry-full" })
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs a channel driver through the direct executor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent = yield* Ref.make("")
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:55.170Z"
|
||||
},
|
||||
|
||||
+1
-4
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:56.397Z"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"tags": ["prefix:azure", "provider:azure"],
|
||||
"name": "azure/responses-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:54.158Z"
|
||||
},
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai.gpt-oss-120b",
|
||||
"tags": [
|
||||
"prefix:bedrock-mantle",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:openai-responses"
|
||||
],
|
||||
"tags": ["prefix:bedrock-mantle", "provider:amazon-bedrock", "protocol:openai-responses"],
|
||||
"name": "bedrock-mantle/streams-text",
|
||||
"recordedAt": "2026-08-25T03:29:02.968Z"
|
||||
},
|
||||
|
||||
+2
-8
@@ -7,13 +7,7 @@
|
||||
"route": "cloudflare-workers-ai",
|
||||
"transport": "http",
|
||||
"model": "@cf/openai/gpt-oss-20b",
|
||||
"tags": [
|
||||
"prefix:cloudflare-workers-ai",
|
||||
"provider:cloudflare-workers-ai",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
]
|
||||
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -35,4 +29,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "text", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-text",
|
||||
"recordedAt": "2026-08-26T00:34:03.019Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"!\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":3.46e-6,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-call", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-call",
|
||||
"recordedAt": "2026-08-26T00:34:04.173Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_SMfBjXa8eCmHLyjfeyARxe3a\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":0.000027480000000000005,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "deepinfra",
|
||||
"route": "deepinfra-chat",
|
||||
"transport": "http",
|
||||
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-loop", "golden"],
|
||||
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-loop",
|
||||
"recordedAt": "2026-08-26T00:34:05.656Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":0.000028780000000000002,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"\"},{\"role\":\"tool\",\"tool_call_id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Paris\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" is\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" sunny\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\".\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":0.0000292,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
|
||||
+1
-5
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
|
||||
+47
File diff suppressed because one or more lines are too long
Vendored
+47
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai/gpt-oss-20b",
|
||||
"tags": ["prefix:groq-chat", "provider:groq", "protocol:groq-chat", "text", "usage"],
|
||||
"name": "groq-chat/streams-text-with-usage",
|
||||
"recordedAt": "2026-08-26T14:40:09.833Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai/gpt-oss-20b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":512,\"include_reasoning\":false,\"service_tier\":\"on_demand\",\"user\":\"recorded-test\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"seed\":94036161}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}},\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[],\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:openai-responses-cache",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"cache"
|
||||
],
|
||||
"tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"],
|
||||
"name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call",
|
||||
"recordedAt": "2026-08-25T03:29:25.124Z"
|
||||
},
|
||||
|
||||
Vendored
+1
-8
@@ -5,14 +5,7 @@
|
||||
"route": "openai-responses",
|
||||
"transport": "http",
|
||||
"model": "gpt-5.5",
|
||||
"tags": [
|
||||
"prefix:openai-responses",
|
||||
"provider:openai",
|
||||
"flagship",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"tags": ["prefix:openai-responses", "provider:openai", "flagship", "tool", "tool-loop", "golden"],
|
||||
"name": "openai-responses/openai-responses-gpt-5-5-tool-loop",
|
||||
"recordedAt": "2026-08-20T06:30:22.262Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/openai-tool-result",
|
||||
"recordedAt": "2026-08-25T03:29:08.297Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/openai-user-input",
|
||||
"recordedAt": "2026-08-25T03:29:05.645Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/xai-tool-result",
|
||||
"recordedAt": "2026-08-25T03:29:11.774Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:xai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:xai-responses", "user-input"],
|
||||
"name": "pdf/xai-user-input",
|
||||
"recordedAt": "2026-08-25T03:29:10.612Z"
|
||||
},
|
||||
|
||||
+1
-1
@@ -52,4 +52,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:vercel-ai-gateway",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
|
||||
"name": "vercel-ai-gateway-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:42.077Z"
|
||||
},
|
||||
@@ -31,4 +26,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,37 @@ import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
|
||||
describe("Image", () => {
|
||||
for (const provider of [OpenAI, Google, XAI, ZAI]) {
|
||||
const model = provider.configure({ apiKey: "test", baseURL: "https://image.test" }).image("image-model")
|
||||
for (const body of ['{"data":42,"candidates":42,"opaque":{"nested":[1,2]},"trace":"outer"}', '{"invalid":']) {
|
||||
it.effect(`retains ${model.provider} image response body and decode cause: ${body}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Image.generate({ model, prompt: "hello" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("invalid response")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-image-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://image.test/")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(body, {
|
||||
headers: { "content-type": "application/json", "x-image-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.effect("generates images through the OpenAI Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
@@ -530,22 +561,17 @@ describe("Image", () => {
|
||||
Effect.sync(() => {
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||
expect(error.reason.providerMetadata).toEqual({
|
||||
google: {
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
citationMetadata: undefined,
|
||||
groundingMetadata: undefined,
|
||||
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expect(error.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
candidates: [
|
||||
{
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
content: { parts: [{ text: "blocked", thought: false }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -18,13 +18,19 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
test("classifies Anthropic request_too_large as recoverable overflow", () => {
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies generic request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
@@ -33,7 +39,6 @@ describe("provider error classification", () => {
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
@@ -84,9 +89,7 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies network error text as provider internal", () => {
|
||||
expect(
|
||||
["network error", "network-error", "network_error"].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
@@ -108,6 +111,39 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies provider envelopes without separate code inputs", () => {
|
||||
const cases = [
|
||||
['{"type":"error","error":{"type":"overloaded_error","message":"Try again"}}', "ProviderInternal"],
|
||||
['{"error":{"code":"insufficient_quota","message":"Request failed"}}', "QuotaExceeded"],
|
||||
[
|
||||
'{"type":"response.failed","response":{"error":{"code":"authentication_error","message":"Denied"}}}',
|
||||
"Authentication",
|
||||
],
|
||||
['{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","message":"Try again"}}', "ProviderInternal"],
|
||||
['{"exception":{"type":"throttlingException","details":{"message":"Try again"}}}', "RateLimit"],
|
||||
] as const
|
||||
for (const [rawBody, expected] of cases) {
|
||||
const reason = classifyProviderFailure({ message: "Request failed", rawBody })
|
||||
expect(reason._tag).toBe(expected)
|
||||
expect(reason.body).toBe(rawBody)
|
||||
expect(reason).not.toHaveProperty("code")
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies separately supplied SDK data without replacing the response body", () => {
|
||||
const data = { error: { code: "authentication_error" } }
|
||||
for (const value of [data, JSON.stringify(data)]) {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
status: 400,
|
||||
rawBody: '{"message":"Request failed"}',
|
||||
data: value,
|
||||
})
|
||||
expect(reason._tag).toBe("Authentication")
|
||||
expect(reason.body).toBe('{"message":"Request failed"}')
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
|
||||
@@ -28,6 +28,8 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
|
||||
import("@opencode-ai/ai/providers/togetherai"),
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
import("@opencode-ai/ai/providers/groq"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -37,6 +39,24 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
const DeepInfra = await import("@opencode-ai/ai/providers/deepinfra")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://provider.example.test/v1/",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" as const },
|
||||
}
|
||||
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
|
||||
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
|
||||
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
|
||||
expect(error.message).toContain("`tool_use` ids were found without `tool_result` blocks")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
||||
import { GoogleVertexMessages } from "../../src/providers.js"
|
||||
@@ -810,6 +810,99 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips compatible provider metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = Route.make({
|
||||
id: "custom-anthropic-messages",
|
||||
provider: "custom-anthropic",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
framing: AnthropicMessages.framing,
|
||||
}).model({ id: "custom-model" })
|
||||
const result = [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://example.com",
|
||||
citations: [{ type: "web_search_result_location", cited_text: "Example" }],
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5, custom_start: true } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "Thinking." } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "custom_sig" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "redacted_thinking", data: "custom_redacted" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: "server_tool_use",
|
||||
id: "custom_tool",
|
||||
name: "web_search",
|
||||
input: { query: "example" },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 3,
|
||||
content_block: { type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
},
|
||||
{ type: "content_block_stop", index: 3 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "custom_stop" },
|
||||
usage: { output_tokens: 2, custom_terminal: true },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toMatchObject([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: { "custom-anthropic": { signature: "custom_sig" } } },
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-anthropic": { redactedData: "custom_redacted" } } },
|
||||
{ type: "tool-call", id: "custom_tool", providerExecuted: true },
|
||||
{
|
||||
type: "tool-result",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { "custom-anthropic": { blockType: "web_search_tool_result", result } },
|
||||
},
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-anthropic": { input_tokens: 5, custom_start: true, output_tokens: 2, custom_terminal: true },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
providerMetadata: { "custom-anthropic": { stopSequence: "custom_stop" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Thinking.", signature: "custom_sig" },
|
||||
{ type: "redacted_thinking", data: "custom_redacted" },
|
||||
{ type: "server_tool_use", id: "custom_tool", name: "web_search", input: { query: "example" } },
|
||||
{ type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -873,9 +966,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
|
||||
message: "The provider response ended unexpectedly.",
|
||||
})
|
||||
}),
|
||||
@@ -964,8 +1056,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -986,8 +1078,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1013,8 +1105,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1029,8 +1121,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1552,7 +1644,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error: Overloaded" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1570,10 +1662,9 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1585,7 +1676,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1596,7 +1687,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Anthropic Messages stream error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Anthropic Messages stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1613,7 +1704,7 @@ describe("Anthropic Messages route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -569,6 +569,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips reassigned provider reasoning and usage metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "custom-bedrock" }).model({ id: model.id })
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: compatible })).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "custom_sig" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 1, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 1 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me think.",
|
||||
providerMetadata: { "custom-bedrock": { signature: "custom_sig" } },
|
||||
},
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-bedrock": { redactedData } } },
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-bedrock": { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "custom_sig" } } },
|
||||
{ reasoningContent: { redactedContent: redactedData } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
@@ -738,19 +789,32 @@ describe("Bedrock Converse route", () => {
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "A future provider failure" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const payload = { message: "Slow down", details: { opaque: [1, 2] }, trace: "outer", p: "padding" }
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
exceptionFrame("throttlingException", { message: "Slow down" }),
|
||||
exceptionFrame("throttlingException", payload),
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "Slow down" })
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
":exception-type": { type: "string", value: "throttlingException" },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
expect(error.reason.http).toMatchObject({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -763,10 +827,9 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "Input is too long for requested model",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -785,7 +848,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Upstream model failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -796,10 +859,32 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "BadStream: Stream failed",
|
||||
})
|
||||
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
|
||||
headers: { ":error-code": { value: "BadStream" } },
|
||||
body: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains malformed AWS payloads with headers and decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers = {
|
||||
":message-type": { type: "string" as const, value: "event" },
|
||||
":event-type": { type: "string" as const, value: "messageStart" },
|
||||
}
|
||||
const body = '{"malformed":'
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(codec.encode({ headers, body: utf8Encoder.encode(body) }))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({ headers, body })
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
@@ -16,12 +18,16 @@ const credentials = {
|
||||
}
|
||||
|
||||
describe("Amazon Bedrock Mantle provider", () => {
|
||||
it.effect("uses Chat by default and exposes Responses", () =>
|
||||
it.effect("uses Responses by default and exposes Chat explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = AmazonBedrockMantle.configure({ credentials })
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat).toMatchObject({
|
||||
@@ -34,6 +40,23 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const settings = { apiKey: "test-key", topP: 0.8 }
|
||||
const chat = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
|
||||
)
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat.body.top_p).toBe(0.8)
|
||||
expect(responses.body.top_p).toBe(0.8)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -83,6 +106,42 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays reasoning with Mantle's message-prefixed item ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
|
||||
const item = { type: "reasoning", id: "msg_95d4d0af4350432a", encrypted_content: "mantle-state" }
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: item.id, delta: "Considering." },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
mantle: { itemId: "msg_95d4d0af4350432a", reasoningEncryptedContent: "mantle-state" },
|
||||
})
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "msg_95d4d0af4350432a",
|
||||
summary: [{ type: "summary_text", text: "Considering." }],
|
||||
encrypted_content: "mantle-state",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Cloudflare", () => {
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
"cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { Anthropic, Google, OpenAI } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("provider error retention", () => {
|
||||
const options = { apiKey: "test", baseURL: "https://provider.test" }
|
||||
const cases = [
|
||||
{
|
||||
name: "Anthropic",
|
||||
model: Anthropic.configure(options).model("claude"),
|
||||
event: { type: "error", error: { type: "rate_limit_error", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
{
|
||||
name: "OpenAI Chat",
|
||||
model: OpenAI.configure(options).chat("gpt"),
|
||||
event: { error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
{
|
||||
name: "OpenAI Responses",
|
||||
model: OpenAI.configure(options).responses("gpt"),
|
||||
event: {
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_error",
|
||||
error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } },
|
||||
opaque: { upstream: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini",
|
||||
model: Google.configure(options).model("gemini"),
|
||||
event: { error: { code: 429, status: "RESOURCE_EXHAUSTED", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
]
|
||||
|
||||
for (const entry of cases) {
|
||||
it.effect(`retains the complete ${entry.name} event and HTTP context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ ...entry.event, trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(LLM.request({ model: entry.model, prompt: "hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(body), {
|
||||
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.message).toContain("Slow down")
|
||||
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
expect(error.reason.cause).toBeUndefined()
|
||||
expect(error.cause).toBe(error.reason)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("retains malformed provider frames and the original decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Anthropic.configure(options).model("claude"), prompt: "hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the HTTP response context when a channel falls back", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"code":"rate_limit_exceeded","message":"Slow down","extra":42}}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure(options).responses("gpt"), prompt: "hello" }),
|
||||
{
|
||||
webSocket: {
|
||||
execute: (exchange) => Effect.succeed({ frames: exchange.fallback(), complete: Effect.void }),
|
||||
},
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "fallback-1" } })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({
|
||||
url: "https://provider.test/responses",
|
||||
status: 200,
|
||||
headers: { "x-provider-trace": "fallback-1" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -515,7 +515,10 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
{
|
||||
functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } },
|
||||
thoughtSignature: "sig_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -606,10 +609,7 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
{ functionCall: { name: "shot", args: {} } },
|
||||
],
|
||||
parts: [{ functionCall: { name: "shot", args: {} } }, { functionCall: { name: "shot", args: {} } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -1071,7 +1071,9 @@ describe("Gemini route", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
|
||||
messages: [
|
||||
Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.contents).toEqual([
|
||||
@@ -1572,9 +1574,7 @@ describe("Gemini route", () => {
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const }],
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import * as Google from "../../src/providers/google.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
@@ -55,6 +55,9 @@ const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "f
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
)
|
||||
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
|
||||
const openrouter = openRouter.model("openai/gpt-4o-mini")
|
||||
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
|
||||
@@ -222,6 +225,13 @@ describeRecordedGoldenScenarios([
|
||||
requires: ["GROQ_API_KEY"],
|
||||
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
|
||||
},
|
||||
{
|
||||
name: "DeepInfra Llama 3.3 70B",
|
||||
prefix: "deepinfra-chat",
|
||||
model: deepInfra,
|
||||
requires: ["DEEPINFRA_API_KEY"],
|
||||
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
|
||||
},
|
||||
{
|
||||
name: "OpenRouter gpt-4o-mini",
|
||||
prefix: "openai-compatible-chat",
|
||||
|
||||
@@ -26,9 +26,7 @@ const recorded = recordedTests({
|
||||
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" }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Reply with exactly one word: hello" }))
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexRespo
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "../lib/openai-chunks.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("Google Vertex providers", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
@@ -97,7 +97,7 @@ describe("Google Vertex providers", () => {
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -122,6 +122,91 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips Vertex Gemini metadata through signed content, tool calls, and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
promptFeedback: { blockReasonMessage: "Reviewed" },
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-end")
|
||||
const text = response.events.find((event) => event.type === "text-delta")
|
||||
const toolCall = response.toolCalls[0]
|
||||
|
||||
expect(reasoning?.providerMetadata).toEqual({ vertex: { thoughtSignature: "reasoning_sig" } })
|
||||
expect(text?.providerMetadata).toEqual({ vertex: { thoughtSignature: "text_sig" } })
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call_1",
|
||||
providerMetadata: { vertex: { thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vertex: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
})
|
||||
expect(response.events.at(-1)?.providerMetadata).toEqual({
|
||||
vertex: { promptFeedback: { blockReasonMessage: "Reviewed" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: reasoning?.providerMetadata },
|
||||
{ type: "text", text: "Checking.", providerMetadata: text?.providerMetadata },
|
||||
ToolCallPart.make({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
providerMetadata: toolCall.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
Message.tool({ id: toolCall.id, name: toolCall.name, result: "sunny", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { configure } from "@opencode-ai/ai/providers/groq"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.GROQ_API_KEY ?? "fixture"
|
||||
const recorded = recordedTests({
|
||||
prefix: "groq-chat",
|
||||
provider: "groq",
|
||||
protocol: "groq-chat",
|
||||
requires: ["GROQ_API_KEY"],
|
||||
})
|
||||
|
||||
const weather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris", "London"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe("Groq recorded", () => {
|
||||
recorded.effect.with(
|
||||
"streams text with usage",
|
||||
{ tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: {
|
||||
includeReasoning: false,
|
||||
reasoningEffort: "low",
|
||||
serviceTier: "on_demand",
|
||||
user: "recorded-test",
|
||||
},
|
||||
}).model("openai/gpt-oss-20b"),
|
||||
prompt: "Reply with exactly one word: hello",
|
||||
generation: { maxTokens: 512 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 512,
|
||||
stream_options: { include_usage: true },
|
||||
include_reasoning: false,
|
||||
service_tier: "on_demand",
|
||||
user: "recorded-test",
|
||||
})
|
||||
expect(compiled.body.max_tokens).toBeUndefined()
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.toLowerCase().trim()).toBe("hello")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
for (const item of [
|
||||
{
|
||||
name: "continues Qwen parallel tool calls",
|
||||
model: configure({ apiKey, providerOptions: { parallelToolCalls: true, reasoningEffort: "none" } }).model(
|
||||
"qwen/qwen3.6-27b",
|
||||
),
|
||||
cities: ["Paris", "London"],
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
name: "replays GPT OSS reasoning through a tool loop",
|
||||
model: configure({ apiKey, providerOptions: { includeReasoning: true, reasoningEffort: "low" } }).model(
|
||||
"openai/gpt-oss-20b",
|
||||
),
|
||||
cities: ["Paris"],
|
||||
reasoning: true,
|
||||
},
|
||||
]) {
|
||||
recorded.effect.with(
|
||||
item.name,
|
||||
{
|
||||
tags: ["tool", "tool-loop", "usage", item.reasoning ? "reasoning" : "parallel"],
|
||||
metadata: { model: item.model.id },
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: item.model,
|
||||
prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`,
|
||||
tools: [weather],
|
||||
toolChoice: "required",
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body.stream_options).toEqual({ include_usage: true })
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
expect(compiled.body.tools[0].function.strict).toBeUndefined()
|
||||
if (!item.reasoning) expect(compiled.body.parallel_tool_calls).toBe(true)
|
||||
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toHaveLength(item.cities.length)
|
||||
expect(new Set(first.toolCalls.map((call) => call.id)).size).toBe(item.cities.length)
|
||||
expect(first.toolCalls.map((call) => call.input)).toEqual(
|
||||
expect.arrayContaining(item.cities.map((city) => ({ city }))),
|
||||
)
|
||||
expect(first.toolCalls.every((call) => call.name === "lookup_weather")).toBe(true)
|
||||
expectUsage(first)
|
||||
if (item.reasoning) {
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
}
|
||||
|
||||
const followUp = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const replay = yield* compileRequest(followUp)
|
||||
if (item.reasoning) {
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning: first.reasoning })]),
|
||||
)
|
||||
}
|
||||
expect(replay.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
item.cities.forEach((city) => expect(second.text).toContain(city))
|
||||
expectUsage(second)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
}
|
||||
|
||||
recorded.effect.with(
|
||||
"streams Qwen parsed reasoning",
|
||||
{ tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: { reasoningEffort: "default" },
|
||||
}).model("qwen/qwen3.6-27b"),
|
||||
prompt:
|
||||
"What is 173 multiplied by 219? Think through the arithmetic, then reply with only the final integer.",
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({ reasoning_format: "parsed", reasoning_effort: "default" })
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.text).not.toContain("<think>")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
function expectUsage(response: LLMResponse) {
|
||||
expect(response.usage).toBeDefined()
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LanguageModel, LLM, Message } from "../../src/index.js"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
|
||||
import { Groq } from "../../src/providers/groq.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { weatherTool } from "../recorded-scenarios.js"
|
||||
|
||||
it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Groq.protocol.stream).toBe(OpenAIChat.protocol.stream)
|
||||
const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile")
|
||||
expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1")
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 64,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_format: "parsed",
|
||||
})
|
||||
for (const key of ["store", "max_tokens", "include_reasoning", "parallel_tool_calls", "service_tier", "user"])
|
||||
expect(compiled.body[key]).toBeUndefined()
|
||||
expect(compiled.body.tools?.[0]?.function).not.toHaveProperty("strict")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq lowers its own options for custom catalog identities and endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = LanguageModel.update(
|
||||
Groq.model("qwen/qwen3.6-27b", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: {
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
},
|
||||
}),
|
||||
{ provider: "custom-groq" },
|
||||
)
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: false, includeReasoning: false } }),
|
||||
)
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
|
||||
expect(model.route.defaults.http?.body).toEqual({ custom: "value" })
|
||||
expect(compiled.body).toMatchObject({
|
||||
reasoning_effort: "default",
|
||||
reasoning_format: "parsed",
|
||||
parallel_tool_calls: false,
|
||||
service_tier: "flex",
|
||||
user: "test-user",
|
||||
})
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
for (const key of ["reasoningFormat", "reasoningEffort", "parallelToolCalls", "serviceTier"])
|
||||
expect(compiled.body).not.toHaveProperty(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () =>
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"),
|
||||
messages: [
|
||||
Message.user("Think"),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "text", text: "Answer" },
|
||||
]),
|
||||
Message.user("Again"),
|
||||
Message.assistant("Answer only"),
|
||||
Message.user("Continue"),
|
||||
],
|
||||
providerOptions: { reasoningEffort: "low", includeReasoning: false },
|
||||
}),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({ reasoning_effort: "low", include_reasoning: false })
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.messages[1]).toMatchObject({ reasoning: "Thinking", content: "Answer" })
|
||||
expect(compiled.body.messages[1]).not.toHaveProperty("reasoning_content")
|
||||
expect(compiled.body.messages[3]).not.toHaveProperty("reasoning")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }),
|
||||
)
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq validates option types", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const providerOptions of [{ includeReasoning: "false" }, { parallelToolCalls: "false" }]) {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -2,13 +2,80 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import {
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
Anthropic,
|
||||
AnthropicCompatible,
|
||||
Azure,
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
Google,
|
||||
GoogleVertex,
|
||||
GoogleVertexChat,
|
||||
GoogleVertexMessages,
|
||||
GoogleVertexResponses,
|
||||
Groq,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenAICompatibleResponses,
|
||||
OpenRouter,
|
||||
TogetherAI,
|
||||
XAI,
|
||||
} from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const vertex = { project: "project", accessToken: "token" }
|
||||
const providers = [
|
||||
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
|
||||
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
"minimax",
|
||||
],
|
||||
[
|
||||
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
|
||||
"custom",
|
||||
],
|
||||
[
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
|
||||
"model",
|
||||
),
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
|
||||
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
|
||||
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
|
||||
] as const
|
||||
|
||||
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
@@ -29,6 +96,18 @@ describe("native OpenAI-compatible providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native DeepInfra provider and route identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
provider: "deepinfra",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
|
||||
route: { id: "deepinfra-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = yield* compileRequest(
|
||||
@@ -82,6 +161,21 @@ describe("native OpenAI-compatible providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const baseURL of [
|
||||
"https://gateway.example/v1",
|
||||
"https://gateway.example/v1/",
|
||||
"https://gateway.example/v1/openai",
|
||||
"https://gateway.example/v1/openai/",
|
||||
]) {
|
||||
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
|
||||
"https://gateway.example/v1/openai",
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps package settings onto native executable models", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
@@ -122,6 +216,18 @@ describe("native OpenAI-compatible providers", () => {
|
||||
token: "cerebras-secret",
|
||||
url: "https://api.cerebras.ai/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: DeepInfra.configure().model("gemma"),
|
||||
env: { DEEPINFRA_API_KEY: "deepinfra-secret" },
|
||||
token: "deepinfra-secret",
|
||||
url: "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Groq.configure().model("llama"),
|
||||
env: { GROQ_API_KEY: "groq-secret" },
|
||||
token: "groq-secret",
|
||||
url: "https://api.groq.com/openai/v1/chat/completions",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(scenarios, (scenario) =>
|
||||
|
||||
@@ -68,11 +68,13 @@ for (const item of cases) {
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata?.[
|
||||
item.model.route.providerMetadataKey ?? String(item.model.provider)
|
||||
]
|
||||
expect(metadata?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.reasoningDetails)).toBe(item.structured)
|
||||
if (!item.structured) return
|
||||
const details = metadata?.openai?.reasoningDetails
|
||||
const details = metadata?.reasoningDetails
|
||||
if (!Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
@@ -126,7 +128,11 @@ for (const item of cases) {
|
||||
).toMatch(/^Paris is sunny\.?$/)
|
||||
const details = events
|
||||
.filter(LLMEvent.is.reasoningEnd)
|
||||
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||
.map(
|
||||
(event) =>
|
||||
event.providerMetadata?.[item.model.route.providerMetadataKey ?? String(item.model.provider)]
|
||||
?.reasoningDetails,
|
||||
)
|
||||
.find(Array.isArray)
|
||||
expect(Array.isArray(details)).toBe(item.structured)
|
||||
if (!item.structured || !Array.isArray(details)) return
|
||||
|
||||
@@ -903,6 +903,70 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the configured provider metadata namespace for reasoning and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const selected = LanguageModel.update(model, {
|
||||
route: { ...model.route, providerMetadataKey: "vendor" },
|
||||
})
|
||||
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed" }]
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
vendor: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the selected provider for the metadata namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "deepseek" }).model({ id: "deepseek-chat" })
|
||||
const selected = LanguageModel.update(compatible, {
|
||||
route: { ...compatible.route, providerMetadataKey: undefined },
|
||||
})
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning_content: "thinking" }),
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
deepseek: { reasoningField: "reasoning_content" },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||
Effect.gen(function* () {
|
||||
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||
@@ -1393,7 +1457,7 @@ describe("OpenAI Chat route", () => {
|
||||
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
choices: [{ finish_reason: "tool_calls" }],
|
||||
})
|
||||
}),
|
||||
@@ -1470,9 +1534,9 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
expect(error.message).toBe("ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed unexpectedly",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -1488,9 +1552,9 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("ECONNRESET: socket closed before output")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed before output",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -1511,7 +1575,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
outputTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
deepseek: {
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
@@ -489,9 +489,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
})
|
||||
}),
|
||||
@@ -527,11 +526,11 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "ProviderInternal" },
|
||||
message: "Provider reported a network error (finish_reason: network_error)",
|
||||
})
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "network_error" }],
|
||||
})
|
||||
@@ -540,8 +539,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(generic.reason).toMatchObject({
|
||||
_tag: "UnknownProvider",
|
||||
expect(generic).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
}),
|
||||
@@ -562,8 +561,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Provider disconnected" })
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_error",
|
||||
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
|
||||
trace_id: "trace_1",
|
||||
@@ -603,7 +602,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -195,15 +195,19 @@ describe("Open Responses-compatible route", () => {
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// The baseline does not enforce a provider id grammar, so a
|
||||
// non-OpenAI but well-formed token is resent as-is.
|
||||
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
|
||||
// Shape violations are dropped even without a grammar policy.
|
||||
{ type: "text", text: "Kept.", providerMetadata: { "openai-compatible": { itemId: "history_1" } } },
|
||||
{
|
||||
type: "text",
|
||||
text: "Dropped.",
|
||||
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
|
||||
text: "Long.",
|
||||
providerMetadata: { "openai-compatible": { itemId: `history_${"a".repeat(64)}` } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Opaque.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "provider_value/with+symbols" } },
|
||||
},
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { "openai-compatible": { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { "openai-compatible": { itemId: "_item" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -218,8 +222,23 @@ describe("Open Responses-compatible route", () => {
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `history_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Dropped." }],
|
||||
content: [{ type: "output_text", text: "Long." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "provider_value/with+symbols",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Opaque." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "No suffix." },
|
||||
{ type: "output_text", text: "No prefix." },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -248,7 +267,7 @@ describe("Open Responses-compatible route", () => {
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openresponses: { itemId: item.id } },
|
||||
providerMetadata: { example: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -283,11 +302,296 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
{ type: "text", text: "Indexed", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
describe("stream validation", () => {
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
item: { type: "message" },
|
||||
events: [
|
||||
{ type: "response.output_text.delta", delta: "Preserved" },
|
||||
{ type: "response.output_text.done", text: "Preserved" },
|
||||
{ type: "response.refusal.delta", delta: "Preserved" },
|
||||
{ type: "response.refusal.done", refusal: "Preserved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
item: { type: "reasoning", encrypted_content: "encrypted-state" },
|
||||
events: [
|
||||
{ type: "response.reasoning.delta", delta: "Preserved" },
|
||||
{ type: "response.reasoning.done", text: "Preserved" },
|
||||
{ type: "response.reasoning_summary_text.delta", delta: "Preserved" },
|
||||
{ type: "response.reasoning_summary_text.done", text: "Preserved" },
|
||||
{ type: "response.reasoning_text.done", text: "Preserved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup" },
|
||||
events: [
|
||||
{ type: "response.function_call_arguments.delta", delta: '{"query":"Preserved"}' },
|
||||
{ type: "response.function_call_arguments.done", arguments: '{"query":"Preserved"}' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const routings = [
|
||||
{ name: "empty item and event IDs", id: "", item_id: "" },
|
||||
{ name: "empty event ID with registered index", id: "item_1", item_id: "", output_index: 2 },
|
||||
{ name: "empty stored ID with registered index", id: "", item_id: "wrong_item", output_index: 2 },
|
||||
{ name: "empty item and event IDs with registered index", id: "", item_id: "", output_index: 2 },
|
||||
]
|
||||
|
||||
fixtures.forEach((fixture) => {
|
||||
fixture.events.forEach((event) => {
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`${event.type} preserves content with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { ...fixture.item, id: routing.id }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: routing.output_index, item },
|
||||
{ ...event, item_id: routing.item_id, output_index: routing.output_index },
|
||||
{ type: "response.output_item.done", output_index: routing.output_index, item },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const metadata = { "openai-compatible": { itemId: routing.id } }
|
||||
if (fixture.item.type === "function_call") {
|
||||
expect(response.toolCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "Preserved" },
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
])
|
||||
return
|
||||
}
|
||||
if (fixture.item.type === "reasoning") {
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Preserved",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
return
|
||||
}
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Preserved", providerMetadata: metadata },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
expect.objectContaining({ id: routing.id, providerMetadata: metadata }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const address = { item_id: routing.item_id, output_index: routing.output_index }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: routing.output_index,
|
||||
item: { type: "reasoning", id: routing.id },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", ...address, summary_index: 0, delta: "First." },
|
||||
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 0, text: "First." },
|
||||
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 1, text: "Second." },
|
||||
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 1 },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { output: [{ type: "reasoning", id: routing.id, encrypted_content: "final-state" }] },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First.",
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:0`,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reconciles pending empty-ID function arguments from completed output", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", id: "", call_id: "call_1", name: "lookup" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "", delta: '{"query":"partial' },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { output: [{ ...item, arguments: '{"query":"complete"}' }] },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { "openai-compatible": { itemId: "" } },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats null output items as no-ops without disturbing registered items", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: null },
|
||||
{ type: "response.output_item.done", output_index: 0, item: null },
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "Before " },
|
||||
{ type: "response.output_item.added", output_index: 0, item: null },
|
||||
{ type: "response.output_item.done", output_index: 0, item: null },
|
||||
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "after" },
|
||||
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Before after", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(response.events.map((event) => event.type)).toEqual([
|
||||
"step-start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"step-finish",
|
||||
"finish",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
...fixtures.flatMap((fixture) => fixture.events.map((event) => ({ item: fixture.item, event }))),
|
||||
...["response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].map((type) => ({
|
||||
item: { type: "reasoning" },
|
||||
event: { type, summary_index: 0 },
|
||||
})),
|
||||
],
|
||||
(fixture) =>
|
||||
Effect.forEach([undefined, null, 0, false, {}, []], (item_id) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { ...fixture.item, id: "item_1" },
|
||||
},
|
||||
{ ...fixture.event, output_index: 0, item_id },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed output item IDs invalid", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
|
||||
Effect.forEach(fixtures, (fixture) =>
|
||||
Effect.forEach(
|
||||
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
|
||||
(id) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type, item: { ...fixture.item, id } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("streams function calls without optional item ids through the shared baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -362,7 +666,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { openresponses: { itemId: "item_1" } },
|
||||
providerMetadata: { example: { itemId: "item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -394,7 +698,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -443,7 +747,7 @@ describe("Open Responses-compatible route", () => {
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
providerMetadata: { "openai-compatible": { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -502,7 +806,7 @@ describe("Open Responses-compatible route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
|
||||
providerMetadata: { example: { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -591,7 +895,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
|
||||
providerMetadata: { openresponses: { responseId: "resp_1" } },
|
||||
providerMetadata: { example: { responseId: "resp_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
TransportReason,
|
||||
TransportError,
|
||||
Usage,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
@@ -52,10 +53,11 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
return Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: "provider rejected request",
|
||||
body: frame,
|
||||
cause: new Error("provider cause"),
|
||||
http: new HttpContext({ url: "https://provider.test", status: 200, headers: { "x-trace": "trace-1" } }),
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
@@ -744,6 +746,18 @@ describe("OpenAI Responses route", () => {
|
||||
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
|
||||
},
|
||||
})
|
||||
for (const observation of [missing, limit]) {
|
||||
expect(observation.type).toBe("rejected")
|
||||
if (observation.type !== "rejected") continue
|
||||
expect(observation.error.message).toBe("provider rejected request")
|
||||
expect(observation.error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(observation.error.reason.cause).toMatchObject({ message: "provider cause" })
|
||||
expect(observation.error.reason.http).toMatchObject({ status: 200, headers: { "x-trace": "trace-1" } })
|
||||
expect(ProviderShared.decodeJson(observation.error.reason.body ?? "")).toMatchObject({
|
||||
type: "error",
|
||||
error: { code: expect.any(String) },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1110,9 +1124,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("marks post-send WebSocket failures with delivery state", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = new AIError({
|
||||
module: "test",
|
||||
method: "receive",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: "socket closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
@@ -2229,6 +2241,35 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts empty IDs for native reasoning text deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "" } },
|
||||
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "", delta: "Raw" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: { type: "reasoning", id: "", encrypted_content: "state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Raw",
|
||||
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to item ids when an output index was not registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -3150,38 +3191,50 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops replayed item ids outside the server's grammar", () =>
|
||||
it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
// Fails the message id prefix.
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
providerMetadata: { openai: { itemId: "history_1" } },
|
||||
},
|
||||
// Oversized for the Responses item id limit.
|
||||
{
|
||||
type: "text",
|
||||
text: "World",
|
||||
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
|
||||
providerMetadata: { openai: { itemId: `message_${"a".repeat(64)}` } },
|
||||
},
|
||||
// Fails the reasoning id prefix, so the whole item is unreplayable
|
||||
// statelessly and is skipped rather than sent malformed.
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Missing suffix.",
|
||||
providerMetadata: { openai: { itemId: "rs_", reasoningEncryptedContent: "another-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "No prefix separator.",
|
||||
providerMetadata: { openai: { itemId: "550e8400-e29b-41d4-a716-446655440000" } },
|
||||
},
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { openai: { itemId: "toolu_01A" } },
|
||||
}),
|
||||
ToolCallPart.make({
|
||||
id: "call_2",
|
||||
name: "lookup",
|
||||
input: { query: "news" },
|
||||
providerMetadata: { openai: { itemId: "fc_" } },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -3190,18 +3243,44 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "Hello" },
|
||||
{ type: "output_text", text: "World" },
|
||||
],
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `message_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "World" }],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "thinking_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "Missing suffix." }],
|
||||
encrypted_content: "another-state",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "No prefix separator." }],
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "toolu_01A",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_2",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"news"}',
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -4368,7 +4447,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "rate_limit_exceeded: Slow down" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4379,7 +4458,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4390,7 +4469,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4415,8 +4494,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "ProviderInternal" },
|
||||
message: "server_error: Upstream model unavailable",
|
||||
})
|
||||
}),
|
||||
@@ -4436,7 +4515,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "invalid_prompt" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4459,10 +4538,9 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -4487,10 +4565,9 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -4512,7 +4589,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Something went wrong" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4524,8 +4601,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"error":null')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4537,8 +4614,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"type":"error"')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4550,8 +4627,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"resp_failed_3"')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4568,7 +4645,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("OpenRouter", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
@@ -328,7 +328,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -354,7 +354,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "AB",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -80,7 +80,9 @@ describe("Z.ai Images", () => {
|
||||
|
||||
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
|
||||
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image(
|
||||
"model",
|
||||
),
|
||||
prompt: "A red circle \uD800 on a white background \u{1F600}",
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError, LLM } from "../src/index.js"
|
||||
import { Anthropic } from "../src/providers.js"
|
||||
import { Auth, Framing, HttpTransport, LLMClient, Route } from "../src/route.js"
|
||||
import { fixedResponse, truncatedStream } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
describe("Route.with", () => {
|
||||
test("merges endpoint query and header defaults while replacing auth and id", () => {
|
||||
@@ -40,4 +46,185 @@ describe("Route.with", () => {
|
||||
"x-patch": "patch",
|
||||
})
|
||||
})
|
||||
|
||||
test("assigns metadata ownership to a replacement provider and preserves explicit overrides", () => {
|
||||
const route = OpenAIChat.route.with({ provider: "azure" })
|
||||
const overridden = route.with({ providerMetadataKey: "custom-azure" }).with({ headers: { "x-test": "value" } })
|
||||
|
||||
expect(route.providerMetadataKey).toBe("azure")
|
||||
expect(overridden.providerMetadataKey).toBe("custom-azure")
|
||||
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Route diagnostics", () => {
|
||||
const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://provider.test/v1" } })
|
||||
const request = LLM.request({ model: route.model({ id: "test" }), prompt: "Hello" })
|
||||
const headers = { "content-type": "text/event-stream", "x-request-id": "req_stream" }
|
||||
|
||||
it.effect("retains an entire invalid event and its validation cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = '{ "choices": "invalid", "diagnostic": { "detail": "original" } }'
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.cause).toMatchObject({ _tag: "SchemaError" })
|
||||
expect(error.reason.http).toEqual(
|
||||
new HttpContext({ url: "https://provider.test/v1/chat/completions", status: 200, headers }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains original provider error fields discarded by the event schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame =
|
||||
'{ "error": { "message": "Rate limit exceeded", "code": "rate_limit_exceeded", "debug": { "trace": "original" } }, "request_id": "req_original" }'
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.message).toBe("Rate limit exceeded")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.http?.headers["x-request-id"]).toBe("req_stream")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves semantic parser bodies while replacing serialized event fallbacks", () =>
|
||||
Effect.forEach([undefined, '{"query": BROKEN}', ""], (body) =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("parser failure")
|
||||
const http = new HttpContext({
|
||||
url: "https://upstream.test/v1",
|
||||
status: 202,
|
||||
headers: { "x-trace": "precise" },
|
||||
})
|
||||
const frame = '{ "type": "failure", "debug": "not in schema" }'
|
||||
const failing = Route.make({
|
||||
id: "diagnostics",
|
||||
provider: "test",
|
||||
endpoint: route.endpoint,
|
||||
framing: Framing.sse,
|
||||
protocol: {
|
||||
...OpenAIChat.protocol,
|
||||
stream: {
|
||||
event: Schema.fromJsonString(Schema.Struct({ type: Schema.String })),
|
||||
initial: () => undefined,
|
||||
step: (_state, event) =>
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Parser failed",
|
||||
body: body ?? JSON.stringify(event),
|
||||
http,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(frame), { headers })), Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Parser failed")
|
||||
expect(error.reason.body).toBe(body ?? frame)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toBe(http)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("retains malformed assembled Anthropic hosted-tool arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"query": BROKEN}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Anthropic.configure({ apiKey: "test", baseURL: "https://provider.test" }).model("claude"),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id: "srv1", name: "web_search" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query": ' },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "BROKEN}" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
),
|
||||
{ headers },
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds successful HTTP metadata without replacing framing diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("frame checksum mismatch")
|
||||
const failure = new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Invalid frame",
|
||||
body: "original frame representation",
|
||||
cause,
|
||||
}),
|
||||
})
|
||||
const failing = route.with({
|
||||
transport: HttpTransport.httpJson({ framing: { id: "failure", frame: () => Stream.fail(failure) } }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
|
||||
).pipe(Effect.provide(fixedResponse("wire bytes", { headers })), Effect.flip)
|
||||
|
||||
expect(error.reason.body).toBe(failure.reason.body)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.message).toBe(failure.message)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
expect(error.reason.http?.headers).toEqual(headers)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the original read error after successful response headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("socket disconnected")
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(truncatedStream([], cause)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read" })
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains successful response headers on an incomplete stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("", { headers })), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.reason.http?.headers).toEqual(headers)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user