Compare commits

..
Author SHA1 Message Date
Kit Langton b7d0582a1f fix(plugin): own effect plugin runtime 2026-08-24 09:17:45 -04:00
1165 changed files with 23945 additions and 85419 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Correct directory page headings when the read offset is zero.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Export the Effect runtime used by Effect plugins and safely adapt its tool schemas across host module instances.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Remove the unused question request API and use session forms for question tool interactions.
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
---
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+1 -10
View File
@@ -135,16 +135,7 @@ jobs:
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
// GitHub only populates closingIssuesReferences when a PR targets the repository's
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
const body = pr.body || '';
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : body;
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
if (!hasLinkedIssue) {
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
+7 -71
View File
@@ -22,36 +22,6 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
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
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -71,7 +41,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -111,35 +80,15 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
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
if: runner.os == 'Linux'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
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
if: always()
timeout-minutes: 10
@@ -178,7 +127,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
@@ -189,38 +138,32 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -228,30 +171,23 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:built
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local
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'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+6
View File
@@ -41,5 +41,11 @@
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
},
"options": {
"typeAware": true
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
}
+358 -214
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@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"]
[test]
root = "./do-not-run-tests-from-root"
+1 -2
View File
@@ -63,8 +63,7 @@ new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
command: "bun turbo build",
output: "./dist",
},
})
+9 -7
View File
@@ -4,7 +4,7 @@
bun,
nodejs,
darwin,
callPackage,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
autoPatchelfHook,
@@ -13,7 +13,7 @@
opencode,
}:
let
electron = callPackage ./electron.nix { };
electron = electron_41;
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
@@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: {
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron derives X11 WM_CLASS from app.name.
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
@@ -67,10 +67,12 @@ stdenv.mkDerivation (finalAttrs: {
# https://github.com/electron/electron/issues/31121
# mac builds use a .app bundle which doesnt have this issue
+ lib.optionalString stdenv.isLinux ''
substituteInPlace \
packages/desktop/src/main/windows/appearance.ts \
packages/desktop/src/main/service/desktop-cli.ts \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = ''
-13
View File
@@ -1,13 +0,0 @@
{ callPackage, path }:
let
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
in
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
}
}
+23 -27
View File
@@ -39,24 +39,24 @@
"packages/stats/*"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.112",
"@effect/platform-node": "4.0.0-rc.112",
"@effect/platform-node-shared": "4.0.0-rc.112",
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
"@effect/opentelemetry": "4.0.0-rc.111",
"@effect/platform-node": "4.0.0-rc.111",
"@effect/platform-node-shared": "4.0.0-rc.111",
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.13",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
@@ -67,16 +67,13 @@
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/event-listener": "2.4.6",
"@solid-primitives/media": "2.3.6",
"@solid-primitives/resize-observer": "2.2.0",
"@solid-primitives/storage": "4.4.0",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.4",
"dompurify": "3.4.14",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.5-ab785fc",
"drizzle-orm": "1.0.0-rc.5-169397b",
"effect": "4.0.0-rc.112",
"effect": "4.0.0-rc.111",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -84,9 +81,9 @@
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "18.0.11",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
@@ -95,17 +92,17 @@
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.3.6",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "1.0.0",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.71.0",
"@sentry/vite-plugin": "5.4.0",
"solid-js": "1.9.15",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
@@ -161,8 +158,7 @@
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
@@ -170,7 +166,7 @@
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
-8
View File
@@ -19,14 +19,6 @@ 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.
+11 -29
View File
@@ -214,40 +214,22 @@ the requests sent by code under test:
import { Effect } from "effect"
import { TestLLM } from "@opencode-ai/ai/testing"
const testLLM = TestLLM.layer({
fallback: TestLLM.text("Hello from the test model", "text-1"),
})
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
const programWithTestClient = Effect.gen(function* () {
const test = yield* TestLLM.Test
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
const result = yield* program
console.log(yield* test.requests())
const test = yield* TestLLM.Service
console.log(test.requests)
return result
}).pipe(Effect.provide(TestLLM.testLayer()))
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
```
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
normal client; tests use the additional controls. Each layer build has fresh state.
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
other without changing queued replies; queued replies take precedence.
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
`serve` receives the original canonical request.
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
already provide Scope.
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
future reply.
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
it does not repair or truncate them.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
available on the yielded `TestLLM.Service`.
## Caching
+5 -5
View File
@@ -157,9 +157,9 @@ const PROVIDERS: ReadonlyArray<Provider> = [
id: "togetherai",
label: "TogetherAI",
tier: "compatible",
note: "Native Together AI text/tool recorded tests",
vars: [{ name: "TOGETHER_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_API_KEY)),
note: "Existing OpenAI-compatible text/tool recorded tests",
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
},
{
id: "minimax",
@@ -200,8 +200,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
{
id: "cerebras",
label: "Cerebras",
tier: "compatible",
note: "Native Cerebras text/tool/tool-loop recorded tests",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "CEREBRAS_API_KEY" }],
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
},
+1 -6
View File
@@ -36,12 +36,7 @@ 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", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
+2 -14
View File
@@ -1,8 +1,7 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor.js"
import { mergeHttpOptions, type AIError } from "./schema/index.js"
import { sanitizeSurrogates } from "./utils/sanitize.js"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
import type { AIError } from "./schema/index.js"
export type Execute = RequestExecutor.Interface["execute"]
@@ -27,18 +26,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) =>
request.model.route.generate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
generate: (request) => request.model.route.generate(request, executor.execute),
})
}),
)
+4 -5
View File
@@ -1,7 +1,7 @@
import { Effect, Schema } from "effect"
import {
HttpOptions,
InvalidRequestError,
InvalidRequestReason,
AIError,
ModelID,
ProviderID,
@@ -158,10 +158,9 @@ export function generate(input: ImageRequest | ImageRequestInput) {
try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) =>
new AIError({
reason: new InvalidRequestError({
message: error instanceof Error ? error.message : String(error),
cause: error,
}),
module: "Image",
method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
}),
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
}
+7 -4
View File
@@ -3,7 +3,7 @@ import { LLMClient, Service } from "./route/client.js"
import {
GenerationOptions,
HttpOptions,
InvalidProviderOutputError,
InvalidProviderOutputReason,
AIError,
LLMEvent,
LLMRequest,
@@ -116,7 +116,9 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
)
if (!call || !LLMEvent.is.toolCall(call))
return yield* new AIError({
reason: new InvalidProviderOutputError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
}),
})
@@ -124,9 +126,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
Effect.mapError(
(error) =>
new AIError({
reason: new InvalidProviderOutputError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: tool input failed schema decode: ${error.message}`,
cause: error,
}),
}),
),
+78 -201
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -69,22 +69,14 @@ 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
@@ -165,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.String,
signature: Schema.optional(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -267,11 +259,7 @@ 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([
@@ -373,8 +361,6 @@ const AnthropicStreamBlock = Schema.Struct({
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
type: Schema.optional(Schema.String),
@@ -385,15 +371,13 @@ const AnthropicStreamDelta = Schema.Struct({
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
@@ -406,7 +390,6 @@ 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
@@ -441,18 +424,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
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 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 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 redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
@@ -512,21 +495,14 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
return undefined
}
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
part: ToolResultPart,
providerMetadataKey: string,
) {
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
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?.[providerMetadataKey]?.["result"] ?? part.result.value
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
content: payload,
} satisfies AnthropicServerToolResultBlock
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
})
const fileIdFromMetadata = (metadata: MediaPart["metadata"]): string | undefined => {
@@ -574,7 +550,9 @@ 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)
@@ -723,25 +701,6 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
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()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -761,12 +720,9 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
const previous = request.messages[index - 1]
const next = request.messages[index + 1]
// Vertex currently rejects/404s for a system message after local tool results,
// so fold it into the user tool-result turn across continuations and history.
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
return (
previous !== undefined &&
previous.role !== "system" &&
@@ -808,13 +764,12 @@ 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") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
@@ -852,30 +807,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
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, providerMetadataKey)
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
// Mirrors Vercel's @ai-sdk/anthropic: 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)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
@@ -884,7 +824,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "tool-result" && part.providerExecuted) {
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
content.push(yield* lowerServerToolResult(part))
continue
}
return yield* invalid(
@@ -918,24 +858,21 @@ 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 ??
@@ -986,7 +923,8 @@ 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 =
@@ -1074,7 +1012,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, providerMetadataKey: string): Usage | undefined => {
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
@@ -1088,7 +1026,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { [providerMetadataKey]: usage },
providerMetadata: { anthropic: usage },
})
}
@@ -1097,7 +1035,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
// 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, providerMetadataKey: string) => {
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
if (!left) return right
if (!right) return left
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
@@ -1115,9 +1053,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
[providerMetadataKey]:
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
{},
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
},
})
}
@@ -1135,7 +1071,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, providerMetadataKey: string): LLMEvent | undefined => {
const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1149,7 +1085,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey:
providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
})
}
@@ -1158,14 +1094,11 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
const usage = mapUsage(event.message?.usage)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
}
const onContentBlockStart = (
state: ParserState,
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
@@ -1211,16 +1144,14 @@ const onContentBlockStart = (
if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const metadata =
block.signature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [
{
...state,
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
@@ -1243,14 +1174,14 @@ const onContentBlockStart = (
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
anthropicMetadata({ redactedData: block.data }),
),
},
events,
]
}
const result = serverToolResultEvent(block, state.providerMetadataKey)
const result = serverToolResultEvent(block)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
@@ -1258,12 +1189,11 @@ const onContentBlockStart = (
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
state: ParserState,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
event: AnthropicEvent,
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
@@ -1272,7 +1202,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{
@@ -1285,7 +1214,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
{
...state,
@@ -1330,7 +1258,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
signature === undefined ? undefined : anthropicMetadata({ signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
@@ -1338,11 +1266,8 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
})
const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
...state,
@@ -1355,7 +1280,7 @@ const onMessageDelta = (
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
@@ -1387,80 +1312,32 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
return message || type || "Anthropic Messages stream error"
}
const onError = (event: AnthropicEvent) => {
const message = providerErrorMessage(event)
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({ message, rawBody: body }),
}),
)
}
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const invalidStreamEvent = (event: AnthropicEvent) =>
const onError = (event: AnthropicEvent) =>
Effect.fail(
ProviderShared.eventError(
ADAPTER,
"Invalid anthropic/anthropic-messages stream event",
ProviderShared.encodeJson(event),
),
new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
}),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (!SSE_EVENTS.has(event.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (
event.type !== "content_block_start" &&
event.content_block !== undefined &&
Option.isNone(decodeAnthropicStreamBlock(event.content_block))
)
return invalidStreamEvent(event)
if (
event.type !== "content_block_delta" &&
event.delta !== undefined &&
Option.isNone(decodeAnthropicStreamDelta(event.delta))
)
return invalidStreamEvent(event)
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
if (block.type === "tool_use" || block.type === "server_tool_use") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return onContentBlockDelta(state, { ...event, delta: decoded.value })
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") {
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed(onMessageDelta(state, { ...event, delta: decoded.value }))
}
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
@@ -1482,8 +1359,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
initial: () => ({
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
@@ -1497,9 +1373,10 @@ 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" }),
+46 -43
View File
@@ -212,7 +212,11 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -258,21 +262,19 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ tool: { name } }) as const,
})
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const reasoningSignature = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return (
part.encrypted ??
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
)
}
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
? metadata.redactedData
: undefined
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -320,7 +322,6 @@ 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") {
@@ -368,18 +369,12 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part, providerMetadataKey)
const redactedData = reasoningRedactedData(part, providerMetadataKey)
const signature = reasoningSignature(part)
const redactedData = reasoningRedactedData(part)
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
@@ -388,7 +383,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -475,7 +470,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, providerMetadataKey: string): Usage | undefined => {
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
@@ -489,14 +484,12 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { [providerMetadataKey]: usage },
providerMetadata: { bedrock: usage },
})
}
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
@@ -552,14 +545,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedData = reasoning.redactedContent ?? reasoning.data
const metadata = reasoning.signature
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
const providerMetadata = reasoning.signature
? bedrockMetadata({ signature: reasoning.signature })
: redactedData !== undefined
? providerMetadata(state.providerMetadataKey, { redactedData })
? bedrockMetadata({ redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || metadata !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
reasoning.text !== undefined || providerMetadata !== undefined
? Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${index}`,
reasoning.text ?? "",
providerMetadata,
)
: state.lifecycle
return [
{
@@ -575,7 +574,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -602,7 +600,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
events,
`reasoning-${index}`,
state.reasoningSignatures[index]
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
@@ -614,7 +612,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -640,7 +637,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
return [
{
...state,
@@ -653,14 +650,22 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
}
if (event.exception) {
const message =
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error"
const body = ProviderShared.encodeJson(event)
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message,
rawBody: body,
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
}),
})
}
@@ -703,17 +708,15 @@ export const protocol = Protocol.make({
},
stream: {
event: BedrockEvent,
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
initial: () => ({
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
}),
step,
onHalt: (state) => Effect.succeed(onHalt(state)),
onHalt,
},
})
@@ -1,7 +1,6 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { Effect, Encoding, Stream } from "effect"
import { AIError, AIErrorReason } from "../schema/index.js"
import { Effect, Stream } from "effect"
import { Framing } from "../route/framing.js"
import { ProviderShared } from "./shared.js"
@@ -50,14 +49,10 @@ 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
@@ -66,7 +61,6 @@ 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 =
@@ -76,6 +70,7 @@ 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
@@ -85,21 +80,9 @@ 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 }),
rawBody: body,
})
out.push({ [eventType]: parsed })
}
return [cursor, out] as const
})
@@ -112,7 +95,6 @@ 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))),
})
+27 -88
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -6,7 +6,6 @@ import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
Usage,
type FinishReason,
@@ -18,7 +17,6 @@ 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"
@@ -127,7 +125,6 @@ const GeminiContentPart = Schema.Union([
GeminiFunctionCallPart,
GeminiFunctionResponsePart,
])
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
const GeminiContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
@@ -135,11 +132,6 @@ const GeminiContent = Schema.Struct({
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
const GeminiResponseContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(Schema.Unknown)),
})
const GeminiSystemInstruction = Schema.Struct({
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
})
@@ -208,7 +200,7 @@ const GeminiUsage = Schema.Struct({
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: optionalNull(GeminiResponseContent),
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
})
@@ -223,7 +215,6 @@ 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),
@@ -231,8 +222,6 @@ const GeminiEvent = Schema.Struct({
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly providerMetadataKey: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -289,23 +278,22 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
const value = metadata?.[key]
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
? value.thoughtSignature
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
? google.thoughtSignature
: undefined
}
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
thoughtSignature: thoughtSignature(part.providerMetadata),
})
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
@@ -347,19 +335,15 @@ 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, metadataKey) })
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "reasoning") {
parts.push({
text: part.text,
thought: true,
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
const lowered = lowerToolCall(part, omitCallIds)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
@@ -507,7 +491,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, metadataKey: string) => {
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
@@ -528,7 +512,7 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: providerMetadata(metadataKey, usage),
providerMetadata: { google: usage },
})
}
@@ -576,15 +560,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
lifecycle,
events,
"reasoning-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
"text-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -593,32 +572,16 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
},
usage: state.usage,
providerMetadata:
state.promptFeedback === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
state.promptFeedback === undefined ? undefined : googleMetadata({ 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.providerMetadataKey) ?? state.usage)
: state.usage,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
}
const candidate = event.candidates?.[0]
if (!candidate?.content)
@@ -635,21 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const input of candidate.content.parts ?? []) {
if (
ProviderShared.isRecord(input) &&
!("text" in input) &&
!("inlineData" in input) &&
!("functionCall" in input) &&
!("functionResponse" in input)
)
continue
const decoded = decodeGeminiContentPart(input)
if (Option.isNone(decoded))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const part = decoded.value
for (const part of candidate.content.parts ?? []) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -662,7 +611,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
)
continue
}
@@ -670,16 +619,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue
@@ -699,9 +646,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
@@ -709,9 +654,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
id,
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
: undefined,
providerMetadata:
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
}),
)
hasToolCalls = true
@@ -747,14 +691,9 @@ export const protocol = Protocol.make({
},
stream: {
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(),
}),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: (state) => Effect.succeed(finish(state)),
onHalt: finish,
},
})
+34 -11
View File
@@ -9,7 +9,15 @@ import {
type ImageRoute,
} from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { AIError, Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
type ProviderMetadata,
} from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -116,6 +124,13 @@ 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)
@@ -154,9 +169,11 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
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 payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
)
const candidates = decoded.candidates ?? []
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
@@ -191,10 +208,9 @@ export const model = (input: ModelInput) => {
)
const images = yield* Effect.forEach(encoded, (item) =>
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
Effect.mapError((cause) =>
output.invalid(
Effect.mapError(() =>
invalidOutput(
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
cause,
),
),
Effect.map(
@@ -221,10 +237,16 @@ export const model = (input: ModelInput) => {
const finishReasons = candidates.flatMap((candidate) =>
candidate.finishReason === undefined ? [] : [candidate.finishReason],
)
return yield* output.invalid(
return yield* invalidOutput(
`Google Images returned no final images${
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
}; inspect body for prompt feedback and candidate details`,
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
{
google: {
promptFeedback: decoded.promptFeedback,
candidates: candidateMetadata,
},
},
)
}
const usage = decoded.usageMetadata
@@ -268,11 +290,12 @@ 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).pipe(
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
Effect.flatMap((decoded) => {
if (decoded === undefined)
return Effect.fail(
ImageInputs.invalid(
ADAPTER,
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
),
)
@@ -282,7 +305,7 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
}),
)
return Effect.fail(
ImageInputs.invalid("Google generateContent requires Gemini file URIs rather than provider file IDs"),
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
)
}
@@ -1,7 +1,6 @@
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,
@@ -61,8 +60,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
Effect.mapError(() =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
),
)
if (terminal)
@@ -74,13 +73,13 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
Effect.mapError(() =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
}
}
if (event.type === "response.failed") {
@@ -93,7 +92,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} response failed`, frame),
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
}
}
if (event.type === "response.created") {
@@ -176,37 +175,23 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
channel,
}
}),
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
},
}
}),
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)
},
}
}
@@ -1,4 +1,4 @@
import { AIError, TransportError } from "../schema/index.js"
import { AIError, TransportReason } 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({
reason: new TransportError({
module: input.id,
method: "stream",
reason: new TransportReason({
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,16 +137,15 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
)
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(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
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 (observation.type !== "completed") return observation
const responseID = event.response?.id
@@ -155,12 +154,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
} satisfies CheckpointValue,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
},
}
}),
File diff suppressed because it is too large Load Diff
+99 -352
View File
@@ -3,16 +3,11 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
AIErrorReason,
InvalidProviderOutputError,
LLMEvent,
ProviderInternalError,
UnknownProviderError,
Usage,
type FinishReason,
type FinishReasonDetails,
@@ -56,12 +51,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -143,7 +133,6 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -229,25 +218,17 @@ const OpenAIChatChoice = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.StructWithRest(
Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const OpenAIChatEvent = Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
const DONE = "[DONE]" as const
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)])
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
@@ -257,7 +238,6 @@ interface PendingToolDelta {
}
export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
@@ -270,7 +250,6 @@ export interface ParserState {
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
readonly requireFinishReason: boolean
}
// =============================================================================
@@ -283,21 +262,14 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -310,8 +282,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
type: "function",
function: {
name: part.name,
@@ -329,18 +301,17 @@ 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, providerMetadataKey: string) => {
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
const details = part.providerMetadata?.openai?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
@@ -370,9 +341,8 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions & { readonly providerMetadataKey: string },
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -389,31 +359,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part, options))
toolCalls.push(lowerToolCall(part))
continue
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
const observedField = reasoning
.map((part) => reasoningField(part, options.providerMetadataKey))
.find((value) => value !== undefined)
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) =>
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined)
return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
@@ -441,7 +405,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -451,7 +415,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -467,13 +431,11 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions & { readonly providerMetadataKey: string },
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
})
@@ -494,43 +456,12 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const requireReasoning =
request.model.compatibility?.requireReasoning ??
(request.model.compatibility?.reasoningField !== undefined ||
request.model.provider === "deepseek" ||
request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") ||
modelID.includes("deepseek"))
const reasoningField = request.model.compatibility?.reasoningField
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 (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)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -573,19 +504,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
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)
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
}
flushImages()
return messages
@@ -602,122 +528,11 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// 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 p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
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 isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (provider: string, baseURL: string | undefined, modelID: string): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.promptCacheKey(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
@@ -736,19 +551,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
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 zaiToolStream =
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
@@ -762,13 +566,11 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -778,7 +580,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request),
}
})
@@ -788,36 +590,14 @@ 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 mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "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* 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
case "length":
return "length" as const
case "content_filter":
return "content-filter" as const
case "function_call":
case "tool_calls":
return "tool-calls" as const
default:
return "unknown" as const
}
})
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "stop") return "stop"
if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown"
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
@@ -827,14 +607,15 @@ 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"], providerMetadataKey: string): Usage | undefined => {
const mapUsage = (usage: OpenAIChatEvent["usage"]): 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))
@@ -846,7 +627,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string):
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { [providerMetadataKey]: usage },
providerMetadata: { openai: usage },
})
}
@@ -920,12 +701,8 @@ 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 = (
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
) => ({
[providerMetadataKey]: {
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
@@ -933,32 +710,27 @@ const reasoningMetadata = (
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error) {
const body = ProviderShared.encodeJson(event)
if (event.error)
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
})
}
const events: LLMEvent[] = []
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage =
mapUsage(event.usage, state.providerMetadataKey) ??
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
state.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : 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 !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
@@ -977,11 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
@@ -989,7 +757,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const deltaMetadata = reasoningMetadata(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 (
@@ -1000,12 +768,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
@@ -1040,26 +821,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
return yield* new AIError({
reason: AIErrorReason.make({
...result.reason,
message: result.message,
cause: result.reason.cause,
body: ProviderShared.encodeJson(event),
}),
})
if (ToolStream.isError(result)) return yield* result
tools = result.tools
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat tool call delta is missing id or name",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
@@ -1070,7 +839,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
providerMetadataKey: state.providerMetadataKey,
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
@@ -1083,25 +851,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningEmitted,
latestToolIndex,
nextToolIndex,
requireFinishReason: state.requireFinishReason,
},
events,
] as const
})
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
message: "OpenAI Chat stream ended without finish_reason",
classification: "incomplete-stream",
route: ADAPTER,
}),
})
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const toolCallEvents =
state.finishReason === undefined && Object.keys(state.tools).length > 0
? (yield* ToolStream.finishAll(ADAPTER, state.tools)).events
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason
@@ -1110,29 +869,21 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(
state.lifecycle,
events,
"reasoning-0",
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
)
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
})
}
// =============================================================================
// Protocol And OpenAI Route
@@ -1150,9 +901,8 @@ export const protocol = Protocol.make({
from: fromRequest,
},
stream: {
event: OpenAIChatStreamEvent,
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
@@ -1162,16 +912,13 @@ export const protocol = Protocol.make({
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === DONE,
step,
onHalt: finishEvents,
},
})
export const framing = Framing.sseWithDone
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({ framing })
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
export const route = Route.make({
id: ADAPTER,
@@ -1,5 +1,6 @@
import { Route, type RouteRoutedLanguageModelInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import * as OpenAIChat from "./openai-chat.js"
const ADAPTER = "openai-compatible-chat"
@@ -18,7 +19,7 @@ export const route = Route.make({
providerMetadataKey: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: OpenAIChat.framing,
framing: Framing.sse,
})
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
@@ -17,7 +17,6 @@ export const route = Route.make({
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
+28 -12
View File
@@ -9,7 +9,14 @@ import {
type ImageRoute,
} from "../image.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
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"
@@ -77,6 +84,13 @@ 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)
@@ -90,12 +104,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("An OpenAI image mask requires at least one input image")
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
const http = mergeHttpOptions(request.model.http, request.http)
const sourceImages = request.images ?? []
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url)
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.undefined
})
const multipartMask =
@@ -104,7 +118,7 @@ export const model = (input: ModelInput) => {
: mask.type === "bytes"
? { data: mask.data, mediaType: mask.mediaType }
: mask.type === "url"
? yield* ImageInputs.decodeDataUrl(mask.url)
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
: undefined
const useMultipart =
sourceImages.length > 0 &&
@@ -147,7 +161,7 @@ export const model = (input: ModelInput) => {
return undefined
})
if (references.some((image) => image === undefined))
return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
const maskReference =
mask === undefined
? undefined
@@ -159,7 +173,7 @@ export const model = (input: ModelInput) => {
? { file_id: mask.id }
: undefined
if (mask !== undefined && maskReference === undefined)
return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
@@ -195,9 +209,11 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
options: OpenAIImageOptions | undefined,
overlay: Record<string, unknown> | undefined,
) {
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 payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
)
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
const format =
@@ -205,7 +221,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((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)),
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
@@ -225,9 +241,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`))
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* output.invalid("OpenAI Images returned no images")
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
return new ImageResponse({
images,
usage:
+27 -52
View File
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -32,40 +32,6 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("computer_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_preview_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
revised_prompt: optionalNull(Schema.String),
}),
[JsonObject],
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesToolChoice = Schema.Union([
@@ -75,7 +41,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -86,10 +51,28 @@ 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
// references 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 references keep generic
// validation only.
reference: [],
}
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) => {
@@ -122,18 +105,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -142,16 +121,14 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
} satisfies OpenAIResponsesBody
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
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((cause) =>
ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64", undefined, cause),
),
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
)
const format = item.output_format ?? "png"
return {
@@ -185,11 +162,9 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -230,7 +205,7 @@ export const route = Route.make({
endpoint,
auth,
transport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
defaults: { providerOptions: { store: false } },
})
export * as OpenAIResponses from "./openai-responses.js"
+27 -66
View File
@@ -2,12 +2,11 @@ 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, HttpClientResponse } from "effect/unstable/http"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
InvalidProviderOutputError,
InvalidRequestError,
InvalidProviderOutputReason,
InvalidRequestReason,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
@@ -29,10 +28,10 @@ export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const promptCacheKey = (request: LLMRequest): string | undefined => {
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
@@ -97,15 +96,17 @@ 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, body?: string, cause?: unknown) =>
export const eventError = (route: string, message: string, raw?: string) =>
new AIError({
reason: new InvalidProviderOutputError({ route, message, body, cause }),
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
})
export const parseJson = (route: string, input: string, message: string) =>
Effect.try({
try: () => decodeJson(input),
catch: (cause) => eventError(route, message, input, cause),
catch: () => eventError(route, message, input),
})
/**
@@ -207,41 +208,27 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
includeDone = false,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Sse.Event[] = []
return {
output,
parser: Sse.makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message, chunk, error)
return [state, state.output.splice(0)] as const
}),
),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
@@ -249,39 +236,13 @@ export const sseFraming = (
/**
* Canonical invalid-request constructor shared by protocol lowering.
*/
export const invalidRequest = (message: string, cause?: unknown) =>
export const invalidRequest = (message: string) =>
new AIError({
reason: new InvalidRequestError({ message, cause }),
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
})
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"]>,
@@ -328,7 +289,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, error)))
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
/**
* Build an HTTP POST with a JSON body. Sets `content-type: application/json`
@@ -1,10 +1,12 @@
import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image.js"
import { InvalidRequestError, AIError } from "../../schema/index.js"
import { InvalidRequestReason, AIError } from "../../schema/index.js"
const invalid = (message: string, cause?: unknown) =>
const invalid = (module: string, message: string) =>
new AIError({
reason: new InvalidRequestError({ message, cause }),
module,
method: "generate",
reason: new InvalidRequestReason({ message }),
})
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
@@ -12,12 +14,13 @@ 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("Image data URLs must contain a MIME type and base64 data"))
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
Effect.mapError((cause) => invalid("Image data URL contains invalid base64 data", cause)),
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
Effect.map((data) => ({ mediaType: match[1], data })),
)
}
+6 -15
View File
@@ -62,31 +62,22 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
const open = new Set(stepped.text)
open.delete(id)
return { ...stepped, text: open }
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
@@ -29,9 +29,10 @@ 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]
@@ -55,6 +56,7 @@ export const StreamOptions = Schema.Struct({
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
@@ -1,64 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
* Licensed under the MIT License; see partial-json.ts for the complete notice.
*/
/**
* allow partial strings like `"hello \u12` to be parsed as `"hello `
*/
export const STR = 0b000000001
/**
* allow partial numbers like `123.` to be parsed as `123`
*/
export const NUM = 0b000000010
/**
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
*/
export const ARR = 0b000000100
/**
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
*/
export const OBJ = 0b000001000
/**
* allow `nu` to be parsed as `null`
*/
export const NULL = 0b000010000
/**
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
*/
export const BOOL = 0b000100000
/**
* allow `Na` to be parsed as `NaN`
*/
export const NAN = 0b001000000
/**
* allow `Inf` to be parsed as `Infinity`
*/
export const INFINITY = 0b010000000
/**
* allow `-Inf` to be parsed as `-Infinity`
*/
export const _INFINITY = 0b100000000
export const INF = INFINITY | _INFINITY
export const SPECIAL = NULL | BOOL | INF | NAN
export const ATOM = STR | NUM | SPECIAL
export const COLLECTION = ARR | OBJ
export const ALL = ATOM | COLLECTION
/**
* Control what types you allow to be partially parsed.
* The default is to allow all types to be partially parsed, which in most cases is the best option.
*/
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
export default Allow
@@ -1,282 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js
*
* MIT License
*
* Copyright (c) 2023 Promplate Dev Team
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Schema } from "effect"
import { Allow } from "./partial-json-options.js"
export * from "./partial-json-options.js"
export class PartialJSON extends Error {}
export class MalformedJSON extends Error {}
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
const input = jsonString.trim()
if (!input) throw new Error(`${jsonString} is empty`)
try {
return decodeJson(input)
} catch {}
const repaired = repairJSON(input)
if (repaired !== input) {
try {
return decodeJson(repaired)
} catch {}
}
try {
return _parseJSON(input, allowPartial)
} catch (error) {
if (repaired !== input) return _parseJSON(repaired, allowPartial)
throw error
}
}
const repairJSON = (input: string) => {
let repaired = ""
let quoted = false
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (!quoted) {
repaired += character
if (character === '"') quoted = true
continue
}
if (character === '"') {
repaired += character
quoted = false
continue
}
if (character === "\\") {
const next = input[index + 1]
if (next === "u" && /^[0-9a-fA-F]{4}$/.test(input.slice(index + 2, index + 6))) {
repaired += input.slice(index, index + 6)
index += 5
continue
}
if (next !== undefined && '"\\/bfnrtu'.includes(next)) {
repaired += `\\${next}`
index++
continue
}
repaired += "\\\\"
continue
}
const code = character.charCodeAt(0)
repaired += code <= 0x1f ? `\\u${code.toString(16).padStart(4, "0")}` : character
}
return repaired
}
const _parseJSON = (jsonString: string, allow: number) => {
const length = jsonString.length
let index = 0
const markPartialJSON = (message: string): never => {
throw new PartialJSON(`${message} at position ${index}`)
}
const throwMalformedError = (message: string): never => {
throw new MalformedJSON(`${message} at position ${index}`)
}
const parseAny = (): unknown => {
skipBlank()
if (index >= length) markPartialJSON("Unexpected end of input")
if (jsonString[index] === '"') return parseStr()
if (jsonString[index] === "{") return parseObj()
if (jsonString[index] === "[") return parseArr()
if (
jsonString.substring(index, index + 4) === "null" ||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
) {
index += 4
return null
}
if (
jsonString.substring(index, index + 4) === "true" ||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
) {
index += 4
return true
}
if (
jsonString.substring(index, index + 5) === "false" ||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
) {
index += 5
return false
}
if (
jsonString.substring(index, index + 8) === "Infinity" ||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
) {
index += 8
return Infinity
}
if (
jsonString.substring(index, index + 9) === "-Infinity" ||
(Allow._INFINITY & allow &&
1 < length - index &&
length - index < 9 &&
"-Infinity".startsWith(jsonString.substring(index)))
) {
index += 9
return -Infinity
}
if (
jsonString.substring(index, index + 3) === "NaN" ||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
) {
index += 3
return NaN
}
return parseNum()
}
const parseStr = (): string => {
const start = index
let escape = false
index++
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
escape = jsonString[index] === "\\" ? !escape : false
index++
}
if (jsonString.charAt(index) === '"') {
try {
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
} catch (error) {
throwMalformedError(String(error))
}
}
if (Allow.STR & allow) {
try {
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
} catch {
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
}
}
return markPartialJSON("Unterminated string literal")
}
const parseObj = (): Record<string, unknown> => {
index++
skipBlank()
const object: Record<string, unknown> = {}
try {
while (jsonString[index] !== "}") {
skipBlank()
if (index >= length && Allow.OBJ & allow) return object
const key = parseStr()
skipBlank()
index++
try {
Object.defineProperty(object, key, {
value: parseAny(),
enumerable: true,
configurable: true,
writable: true,
})
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
}
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.OBJ & allow) return object
return markPartialJSON("Expected '}' at end of object")
}
index++
return object
}
const parseArr = (): unknown[] => {
index++
const array: unknown[] = []
try {
while (jsonString[index] !== "]") {
array.push(parseAny())
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.ARR & allow) return array
return markPartialJSON("Expected ']' at end of array")
}
index++
return array
}
const parseNum = (): unknown => {
if (index === 0) {
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
try {
return decodeJson(jsonString)
} catch (error) {
if (Allow.NUM & allow) {
try {
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
} catch {}
}
throwMalformedError(String(error))
}
}
const start = index
if (jsonString[index] === "-") index++
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
try {
return decodeJson(jsonString.substring(start, index))
} catch (error) {
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
try {
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
} catch {
throwMalformedError(String(error))
}
}
}
const skipBlank = () => {
while (index < length && " \n\r\t".includes(jsonString[index])) index++
}
return parseAny()
}
export const parse = parseJSON
@@ -34,35 +34,37 @@ 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"
+21 -24
View File
@@ -1,10 +1,8 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
type StreamKey = string | number
const parsePartialInput = Option.liftThrowable(parse)
/**
* One pending streamed tool call. Providers emit the tool identity and JSON
@@ -64,39 +62,38 @@ const inputDelta = (tool: PendingTool, text: string) =>
id: tool.id,
name: tool.name,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
event,
]
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -179,7 +176,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and recover incomplete local arguments when needed.
* from state, and return either a call or a non-executable local input error.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
+24 -10
View File
@@ -2,7 +2,14 @@ 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 { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
Usage,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -87,6 +94,13 @@ 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)
@@ -106,7 +120,7 @@ export const model = (input: ModelInput) => {
return undefined
})
if (imageReferences.some((image) => image === undefined))
return yield* ImageInputs.invalid("xAI Images accepts image URLs, data URLs, bytes, and file IDs")
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
const requestBody = mergeJsonRecords(
{
model: request.model.id,
@@ -135,17 +149,17 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
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 payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
)
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
)
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const mediaType = item.mime_type ?? "application/octet-stream"
if (item.b64_json)
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
Effect.mapError((cause) =>
output.invalid(`xAI Images result ${index} contains invalid base64 data`, cause),
),
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
Effect.map(
(data) =>
new GeneratedImage({
@@ -169,9 +183,9 @@ export const model = (input: ModelInput) => {
: { xai: { revisedPrompt: item.revised_prompt } },
}),
)
return Effect.fail(output.invalid(`xAI Images result ${index} has neither image data nor a URL`))
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* output.invalid("xAI Images returned no images")
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
return new ImageResponse({
images,
+1 -41
View File
@@ -1,52 +1,15 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const XAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("x_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
@@ -72,10 +35,7 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: XAIResponsesBody,
from: fromRequest,
},
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
+21 -6
View File
@@ -2,7 +2,13 @@ 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 { mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
import {
InvalidProviderOutputReason,
AIError,
mergeHttpOptions,
mergeJsonRecords,
type HttpOptions,
} from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { ImageInputs } from "./utils/image-input.js"
@@ -57,6 +63,13 @@ 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)
@@ -69,7 +82,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("Z.ai hosted image generation does not support image inputs")
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
const http = mergeHttpOptions(request.model.http, request.http)
const requestBody = mergeJsonRecords(
{ model: request.model.id, prompt: request.prompt },
@@ -91,11 +104,13 @@ export const model = (input: ModelInput) => {
HttpClientRequest.bodyText(text, "application/json"),
),
)
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 payload = yield* response.json.pipe(
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
)
if (decoded.data.length === 0) return yield* output.invalid("Z.ai Images returned no images")
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
)
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
return new ImageResponse({
images: decoded.data.map(
(item) =>
+64 -61
View File
@@ -1,16 +1,17 @@
import { Option, Schema } from "effect"
import {
AuthenticationError,
ContentPolicyError,
InvalidRequestError,
AuthenticationReason,
ContentPolicyReason,
InvalidRequestReason,
AIError,
ProviderErrorEvent,
ProviderInternalError,
QuotaExceededError,
RateLimitError,
UnknownProviderError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
UnknownProviderReason,
type HttpContext,
type HttpRateLimitDetails,
type ProviderMetadata,
} from "./schema/index.js"
const patterns = [
@@ -37,19 +38,17 @@ const patterns = [
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/range of input length should be/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
]
const payloadPatterns = [/request entity too large/i, /payload too large/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 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)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
@@ -60,7 +59,6 @@ export const isContextOverflowFailure = (failure: unknown) =>
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -76,91 +74,96 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
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
}
// Classification records affirmative evidence about a failure. Deterministic
// failures need positive identification (a 4xx status, quota/auth/policy
// signals); anything unrecognized stays UnknownProvider, which the session
// retry policy treats as retry-eligible because transient failures arrive in
// unpredictable shapes while deterministic rejections almost always carry a
// status or known code.
// 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 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(),
)
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())
// 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 InvalidRequestError({ ...details, classification: "context-overflow" })
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
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" })
if (codes.includes("permission_error"))
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if (
input.status === 429 ||
codes.some(
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
) ||
RATE_LIMIT_TEXT.test(text)
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
)
return new RateLimitError({
...details,
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
status: input.status,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
// Any remaining 4xx is a deterministic rejection of this request.
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
return new UnknownProviderError(details)
if (input.status === 429) {
return new RateLimitReason({
...common,
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,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
function providerCodes(value: unknown) {
const decoded = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
function providerCodes(value: string) {
const decoded = Option.getOrUndefined(decodeJson(value))
if (!isRecord(decoded)) return []
const error = isRecord(decoded.error) ? decoded.error : undefined
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",
)
return [decoded.code, error?.code, error?.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 { Route, type RouteDefaultsInput } from "../route/client.js"
import type { Route as RouteDef, 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,30 +23,22 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = Route.make({
const responsesRoute = OpenAIResponses.route.with({
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: Route<Body, Prepared>, input: Config) => {
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
@@ -78,7 +70,7 @@ export const configure = (input: Config = {}) => {
return {
id,
model: responses,
model: chat,
chat,
responses,
configure,
@@ -96,7 +88,6 @@ 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,
@@ -112,4 +103,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = responsesModel
export const model = chatModel
@@ -35,7 +35,6 @@ 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 { responsesModel as model } from "../amazon-bedrock-mantle.js"
export { chatModel as model } from "../amazon-bedrock-mantle.js"
export type { Settings } from "../amazon-bedrock-mantle.js"
-58
View File
@@ -1,58 +0,0 @@
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("cerebras")
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: "cerebras-chat",
provider: id,
endpoint: { baseURL: profiles.cerebras.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", 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)
-61
View File
@@ -1,61 +0,0 @@
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,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleChat.route.with({
id: "google-vertex-chat",
provider: id,
providerMetadataKey: "vertex",
})
export const routes = [route]
@@ -27,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
providerMetadataKey: "vertex",
providerOptions: { store: false },
})
+1 -1
View File
@@ -68,7 +68,7 @@ const protocol = {
const route = Route.make({
id: "google-vertex-gemini",
provider: id,
providerMetadataKey: "vertex",
providerMetadataKey: "google",
protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
-115
View File
@@ -1,115 +0,0 @@
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 { 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: OpenAIChat.framing,
})
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"
-4
View File
@@ -3,20 +3,16 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
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"
export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
+5 -3
View File
@@ -1,6 +1,7 @@
import { Effect, Schema } from "effect"
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 { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
@@ -8,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -114,10 +115,12 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
@@ -163,10 +166,9 @@ 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: OpenAIChat.framing,
framing: Framing.sse,
})
export const routes = [route]
-58
View File
@@ -1,58 +0,0 @@
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("togetherai")
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: "togetherai-chat",
provider: id,
endpoint: { baseURL: profiles.togetherai.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: 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)
+1 -1
View File
@@ -42,7 +42,7 @@ const responsesRoute = Route.make({
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
defaults: { providerOptions: { store: false } },
})
const chatRoute = Route.make({
+5 -3
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema/index.js"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -137,10 +137,12 @@ 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 AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
? new AuthenticationReason({ message: error.message, kind: "missing" })
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
})
}
return error
+17 -81
View File
@@ -7,19 +7,17 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
AIErrorReason,
GenerationOptions,
HttpOptions,
LLMRequest,
LLMResponse,
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -90,7 +88,6 @@ 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>
@@ -228,14 +225,16 @@ 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, undefined, cause)
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended unexpectedly.",
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
@@ -264,12 +263,11 @@ 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((cause) =>
Effect.mapError(() =>
ProviderShared.eventError(
input.id,
`Invalid ${route} stream event`,
typeof frame === "string" ? frame : ProviderShared.encodeJson(frame),
cause,
),
),
)
@@ -290,16 +288,11 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
const { id, provider, 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,
@@ -324,74 +317,18 @@ 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((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,
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
const stream = Stream.suspend(() => {
let state = protocol.stream.initial(request)
const parsed = events.pipe(
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),
)
const onHalt = protocol.stream.onHalt
return onHalt
? parsed.pipe(
Stream.concat(
Stream.suspend(() => Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable)))),
),
)
: parsed
}).pipe(
const stream = events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
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
}),
@@ -445,8 +382,7 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(resolveRequestOptions(request))
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
+107 -48
View File
@@ -7,7 +7,14 @@ import {
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
import {
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
AIError,
TransportReason,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
export interface Interface {
@@ -33,6 +40,17 @@ 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)
@@ -90,13 +108,24 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
})
}
export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
new HttpContext({
url: response.request.url,
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({
method: request.method,
url: request.url,
headers: headerDetails(request.headers),
})
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
new HttpResponseDetails({
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({
@@ -114,46 +143,81 @@ const providerMessage = (status: number, body: string | void) => {
)
}
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 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,
})
/** Preserve HTTP diagnostics for executor and externally captured failures alike. */
export const httpFailure = (input: {
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: {
readonly message: string
readonly url?: string | undefined
readonly url: string
readonly status?: number | undefined
readonly data?: unknown
readonly code?: string | undefined
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)
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
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 HttpContext({ url: input.url, status: input.status, headers }),
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
}
@@ -180,25 +244,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({
reason: new TransportError({
module: "RequestExecutor",
method: input.operation,
reason: new TransportReason({
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)
? input.error.reason.cause
: input.error
const native = nativeTransportFailure(source)
const code = native?.code
@@ -222,13 +286,6 @@ 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,
@@ -237,7 +294,9 @@ export const stream = (
Stream.unwrap(
Effect.gen(function* () {
const response = yield* executor.execute(request, middleware)
return responseStream(response)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
)
}),
)
@@ -250,7 +309,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),
Effect.flatMap(statusError(request)),
)
const response = yield* middleware(request, (input) =>
@@ -258,7 +317,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)
return yield* statusError(response.request)(response)
})
return Service.of({
execute: executeOnce,
+2 -10
View File
@@ -8,8 +8,8 @@ import type { AIError } from "../schema/index.js"
* `Framing` is the byte-stream-shaped seam between transport and protocol:
*
* - SSE (`Framing.sse`) UTF-8 decode the body, run the SSE channel decoder,
* and emit the `data:` payload of each non-empty event. The default drops
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
* payload of one event.
* - AWS event stream length-prefixed binary frames with CRC checksums.
* Each emitted frame is one parsed binary event record.
*
@@ -19,19 +19,11 @@ 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. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export const sseWithDone: Definition<string> = {
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
}
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
+2 -2
View File
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional effectful flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
/** Optional flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
}
/**
+2 -7
View File
@@ -88,13 +88,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
}
}),
execute: (prepared, _request, runtime) =>
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,
}
Effect.succeed({
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
})
+1 -3
View File
@@ -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, HttpContext, LLMRequest } from "../../schema/index.js"
import type { AIError, LLMRequest } from "../../schema/index.js"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
@@ -11,8 +11,6 @@ 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, HttpContext } from "../../schema/index.js"
import type { AIError } from "../../schema/index.js"
export interface WebSocketChannelExecutor {
readonly execute: (
@@ -10,7 +10,6 @@ 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>
}
+33 -100
View File
@@ -1,13 +1,7 @@
import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import {
AIError,
AIErrorReason,
TransportError,
type HttpContext,
type TransportOperation,
} from "../../schema/index.js"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
import type {
@@ -23,7 +17,6 @@ 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>
@@ -40,22 +33,21 @@ 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?: TransportError["phase"]
readonly delivery?: TransportError["delivery"]
readonly body?: string
readonly cause?: unknown
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) =>
new AIError({
reason: new TransportError({
module: "WebSocketConnector",
method,
reason: new TransportReason({
message,
body: input.body,
cause: input.cause,
transport: "websocket",
operation: input.operation,
url: input.url,
@@ -67,16 +59,22 @@ const transportError = (
const annotateTransportError = (
error: AIError,
input: { readonly phase: TransportError["phase"]; readonly delivery: TransportError["delivery"] },
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) =>
error.reason._tag === "Transport"
? new AIError({
reason: new TransportError({
...error.reason,
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
cause: error.reason.cause,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
phase: input.phase,
delivery: input.delivery,
recovery: error.reason.recovery,
}),
})
: error
@@ -97,7 +95,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(`WebSocket closed before opening (state ${ws.readyState})`, {
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
operation: "request",
code: "closed",
@@ -126,8 +124,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError(`Failed to open WebSocket: ${eventMessage(event)}`, {
cause: "error" in event ? (event.error ?? event) : event,
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
phase: "connect",
@@ -140,9 +137,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError(`WebSocket closed before opening with code ${event.code}`, {
body: event.reason,
cause: event,
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
operation: "request",
code: String(event.code),
@@ -174,8 +169,7 @@ export const toWebSocketUrl = (value: string) =>
throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`)
},
catch: (error) =>
transportError(error instanceof Error ? error.message : "Invalid WebSocket URL", {
cause: error,
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
operation: "request",
code: "invalid-url",
@@ -195,8 +189,7 @@ export const open = (input: WebSocketRequest) =>
headers: input.headers,
}),
catch: (error) =>
transportError(error instanceof Error ? error.message : "Failed to construct WebSocket", {
cause: error,
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
phase: "connect",
@@ -221,8 +214,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket message exceeds the 16 MiB limit", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
url: input.url,
operation: "read",
code: "message-too-large",
@@ -239,8 +231,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket inbound queue overflow", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
operation: "read",
code: "queue-overflow",
@@ -257,8 +248,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("Unsupported WebSocket message payload", {
cause: event,
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
code: "message",
@@ -271,8 +261,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError(`WebSocket error: ${eventMessage(event)}`, {
cause: "error" in event ? (event.error ?? event) : event,
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
code: "message",
@@ -285,9 +274,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError(`WebSocket closed with code ${event.code}`, {
body: event.reason,
cause: event,
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
@@ -311,7 +298,7 @@ export const fromWebSocket = (
Effect.suspend(() => {
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError(`WebSocket is not open (state ${ws.readyState})`, {
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
operation: "write",
phase: "send",
@@ -321,8 +308,7 @@ export const fromWebSocket = (
return Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError(error instanceof Error ? error.message : "Failed to send WebSocket message", {
cause: error,
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
phase: "send",
@@ -363,23 +349,10 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
(connection) => connection.close,
)
const create = yield* exchange.driver.create(undefined)
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,
}),
}),
),
)
yield* connection.sendText(create.message)
const decoder = new TextDecoder()
let observed = false
return {
http: connection.http,
frames: connection.messages.pipe(
Stream.map((message) => {
observed = true
@@ -391,49 +364,9 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
delivery: observed ? "accepted" : "ambiguous",
}),
),
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.mapEffect((frame) => exchange.driver.observe(create, frame)),
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,
}
@@ -484,7 +417,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
const webSocket = options?.webSocket
if (!webSocket) {
return Effect.fail(
transportError("WebSocket JSON transport requires StreamOptions.webSocket", {
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
url: prepared.url,
operation: "request",
code: "unavailable",
@@ -502,7 +435,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
connect: { url: prepared.url, headers: prepared.headers },
fallback: () =>
Stream.fail(
transportError("WebSocket JSON transport does not provide HTTP fallback", {
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
url: prepared.url,
operation: "request",
code: "websocket",
+104 -69
View File
@@ -1,13 +1,19 @@
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 HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
headers: Schema.Record(Schema.String, Schema.String),
}) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
}) {}
@@ -18,58 +24,74 @@ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
const ReasonFields = {
message: Schema.String,
// Preserve the complete original response or triggering event before decoding narrows it.
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
cause: Schema.optional(Schema.Defect({ includeStack: true })),
}
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,
}) {}
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
"Authentication",
ReasonFields,
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
bodyTruncated: Schema.optional(Schema.Boolean),
requestId: Schema.optional(Schema.String),
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
message: Schema.String,
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
"ContentPolicy",
ReasonFields,
) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
}
}
export class ProviderInternalError extends Schema.TaggedError<ProviderInternalError>("AI.Error.ProviderInternal")(
"ProviderInternal",
{
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
},
) {}
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,
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 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 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 const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
@@ -77,12 +99,14 @@ export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportError extends Schema.TaggedError<TransportError>("AI.Error.Transport")("Transport", {
...ReasonFields,
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
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"]),
),
@@ -92,40 +116,51 @@ export class TransportError extends Schema.TaggedError<TransportError>("AI.Error
),
}) {}
export class InvalidProviderOutputError extends Schema.TaggedError<InvalidProviderOutputError>(
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"AI.Error.InvalidProviderOutput",
)("InvalidProviderOutput", {
...ReasonFields,
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class UnknownProviderError extends Schema.TaggedError<UnknownProviderError>("AI.Error.UnknownProvider")(
"UnknownProvider",
ReasonFields,
) {}
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 const AIErrorReason = Schema.Union([
InvalidRequestError,
NoRouteError,
AuthenticationError,
RateLimitError,
QuotaExceededError,
ContentPolicyError,
ProviderInternalError,
TransportError,
InvalidProviderOutputError,
UnknownProviderError,
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
RateLimitReason,
QuotaExceededReason,
ContentPolicyReason,
ProviderInternalReason,
TransportReason,
InvalidProviderOutputReason,
UnknownProviderReason,
]).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(): string {
return this.reason.message
override get message() {
return `${this.module}.${this.method}: ${this.reason.message}`
}
}
+14 -37
View File
@@ -112,8 +112,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -136,8 +134,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -156,8 +152,6 @@ export const ToolInputDelta = Schema.Struct({
id: ToolCallID,
name: Schema.String,
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
@@ -332,32 +326,17 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
for (const event of events) {
if (isDelta(event)) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
}
if (isEnd(event) && event.text !== undefined) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, event.text)
}
}
return order.map((id) => parts.get(id)).join("")
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
events.reduce<Usage | undefined>(
@@ -464,11 +443,10 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -498,11 +476,10 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
const current = state.reasoningParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -600,12 +577,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
usage: Schema.optional(Usage),
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
/** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() {
return responseText(this.events)
}
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
get reasoning() {
return responseReasoning(this.events)
}
-8
View File
@@ -153,16 +153,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.optional(Schema.Boolean),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+52 -103
View File
@@ -1,6 +1,6 @@
export * as TestLLM from "./testing.js"
import { LLMClient } from "./route/client.js"
import { LLMClient, type Interface as LLMClientShape } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
@@ -16,33 +16,13 @@ export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
export type Responder = (request: LLMRequest) => Response
export interface TestInterface extends ClientInterface {
/** Returns a snapshot of requests observed at execution time. */
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
/** Replaces the fallback without changing queued responses. */
readonly always: (response: Response) => Effect.Effect<void>
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
readonly serve: (responder: Responder) => Effect.Effect<void>
/** Waits for request arrivals, not output or completion. */
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
/** @deprecated Use TestInterface through Test and testLayer. */
export interface Interface {
readonly requests: LLMRequest[]
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
readonly always: (response: Response) => Effect.Effect<void>
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
readonly client: ClientInterface
readonly client: LLMClientShape
}
export interface LayerOptions {
@@ -51,7 +31,6 @@ export interface LayerOptions {
readonly fallback?: Response
}
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
export const complete = (
@@ -101,64 +80,59 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
const make = (options: LayerOptions) =>
Effect.sync(() => {
const requests: LLMRequest[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
let fallback: Response | Responder | undefined = options.fallback
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
export const layer = (options: LayerOptions = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const requests: LLMRequest[] = []
const responses: Response[] = []
let started = Deferred.makeUnsafe<void>()
let fallback = options.fallback
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
Effect.suspend(() =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const stream: ClientInterface["stream"] = (request) =>
Stream.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const stream = ((request: LLMRequest) => {
requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
Deferred.doneUnsafe(waiting, Effect.void)
const response = responses.shift() ?? fallback
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`))
const streamed = toStream(response)
const gate = activeGate
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
const streamed = toStream(response)
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
}) as LLMClientShape["stream"]
const client = LLMClient.Service.of({
stream,
generate: (request) =>
stream(request).pipe(
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.flatMap((state) => {
const response = LLMResponse.complete(state)
if (response) return Effect.succeed(response)
return Effect.die("TestLLM response ended without a terminal finish event")
}),
),
})
const test = Test.of({
stream,
generate: (request) =>
stream(request).pipe(
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.flatMap((state) => {
const response = LLMResponse.complete(state)
if (response) return Effect.succeed(response)
return Effect.die("TestLLM response ended without a terminal finish event")
return Service.of({
requests,
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
),
requests: () => Effect.sync(() => [...requests]),
push: (...input) =>
Effect.sync(() => {
responses.push(...input)
}),
always: (response) =>
Effect.sync(() => {
fallback = response
}),
serve: (responder) =>
Effect.sync(() => {
fallback = responder
}),
wait,
gate: () =>
Effect.gen(function* () {
always: (response) =>
Effect.sync(() => {
fallback = response
}),
wait,
gate: Effect.gen(function* () {
const gate = {
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
release: yield* Latch.make(),
@@ -173,36 +147,11 @@ const make = (options: LayerOptions) =>
release,
}
}),
})
return { test, requests }
})
/** Provides one shared implementation under the normal client and test-control tags. */
export const testLayer = (options: LayerOptions = {}) =>
Layer.effectContext(
Effect.map(make(options), (implementation) =>
Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test)),
),
client,
})
}),
)
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
export const layer = (options: LayerOptions = {}) =>
Layer.effect(
Service,
Effect.map(make(options), (implementation) =>
Service.of({
requests: implementation.requests,
push: implementation.test.push,
always: implementation.test.always,
wait: implementation.test.wait,
gate: implementation.test.gate(),
client: implementation.test,
}),
),
)
/** @deprecated testLayer provides LLMClient.Service directly. */
export const clientLayer = Layer.effect(
LLMClient.Service,
Effect.map(Service, (service) => service.client),
-12
View File
@@ -1,12 +0,0 @@
import { isRecord } from "./record.js"
export const sanitizeSurrogates = <T>(value: T): T => {
if (typeof value === "string") return value.toWellFormed() as T
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
if (value instanceof Uint8Array || value instanceof Error) return value
if (isRecord(value))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
) as T
return value
}
+9 -7
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src/index.js"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route.js"
@@ -148,13 +148,15 @@ describe("llm route", () => {
}),
)
test("builds models from configured routes", () => {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
it.effect("builds models from configured routes", () =>
Effect.gen(function* () {
const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
})
expect(configured.model({ id: "fake-model" })).toMatchObject({
provider: "fake-provider",
})
}),
)
it.effect("does not register duplicate route ids globally", () =>
Effect.gen(function* () {
+1 -22
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -86,27 +86,6 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Vertex", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-4-8",
),
system: "You are concise.",
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+2 -69
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { LLM, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_completion_tokens: 30,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -247,73 +247,6 @@ describe("request option precedence", () => {
}),
)
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "hello \uD800 \u{1F600}",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
system: "system \uD800 \u{1F600}",
messages: [
Message.user("user \uDC00"),
Message.assistant([
Message.text("assistant \uD800"),
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
],
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [
{ role: "system", content: "system \uFFFD \u{1F600}" },
{ role: "user", content: "user \uFFFD" },
{
role: "assistant",
content: "assistant \uFFFD",
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
},
{ role: "tool", content: '{"output":"result \uFFFD"}' },
],
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
+201 -372
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
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 { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
@@ -18,86 +18,66 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
)
const responsesLayer = (responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
RequestExecutor.layer.pipe(
Layer.provide(
Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
}),
),
)
}),
),
),
)
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(
fixedResponse(
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",
@@ -105,14 +85,15 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
@@ -123,23 +104,23 @@ 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",
})
}).pipe(
Effect.provide(
fixedResponse(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
{},
),
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
@@ -152,10 +133,8 @@ describe("RequestExecutor", () => {
.pipe(Effect.flip)
expectAIError(error)
expect(error.message).toBe("plugin rejected request")
expect(error.reason.cause).toBeInstanceOf(Error)
expect(error.reason.http).toBeUndefined()
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
@@ -173,11 +152,16 @@ 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(
@@ -204,9 +188,11 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe(
Effect.provide(
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
status: 400,
}),
]),
),
),
)
@@ -220,29 +206,9 @@ 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(fixedResponse("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(
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
),
),
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("does not classify ordinary invalid requests as context overflow", () =>
@@ -253,8 +219,8 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
@@ -263,18 +229,20 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toBe(largeProviderMessage)
expect(error.reason.body).toContain(largeProviderMessage)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
}).pipe(
Effect.provide(
fixedResponse(
JSON.stringify({
model: "test-model",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
@@ -287,9 +255,9 @@ 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(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
@@ -301,7 +269,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
@@ -318,7 +286,7 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
@@ -331,27 +299,38 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
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(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.body).toBe("rate limited")
expect(errorHttp(error)?.body).toBe("rate limited")
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
}),
]),
),
),
)
@@ -362,9 +341,10 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
),
)
@@ -384,18 +364,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
responsesLayer([
new Response("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"x-ratelimit-limit-requests": "500",
"x-ratelimit-limit-tokens": "30000",
"x-ratelimit-remaining-requests": "499",
"x-ratelimit-remaining-tokens": "29900",
"x-ratelimit-reset-requests": "1s",
"x-ratelimit-reset-tokens": "10s",
},
}),
]),
),
),
)
@@ -406,8 +388,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
limit: { requests: "100", "input-tokens": "10000" },
remaining: { requests: "12", "input-tokens": "9000" },
@@ -415,18 +397,20 @@ describe("RequestExecutor", () => {
})
}).pipe(
Effect.provide(
fixedResponse("rate limited", {
status: 429,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
responsesLayer([
new Response("overloaded", {
status: 529,
headers: {
"retry-after-ms": "0",
"anthropic-ratelimit-requests-limit": "100",
"anthropic-ratelimit-requests-remaining": "12",
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "9000",
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
},
}),
]),
),
),
)
@@ -439,20 +423,15 @@ describe("RequestExecutor", () => {
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
: input.respond("ok", { status: 200 })
}),
),
countedResponsesLayer(attempts, [
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
new Response("ok", { status: 200 }),
]),
),
)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.http?.status).toBe(503)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
@@ -465,14 +444,15 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason.http?.status).toBe(status)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe(
Effect.provide(
fixedResponse("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
responsesLayer([
new Response("provider failure", {
status,
headers: { "retry-after-ms": "0" },
}),
]),
),
)
@@ -483,28 +463,21 @@ describe("RequestExecutor", () => {
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const error = yield* Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return yield* executor.execute(request).pipe(Effect.flip)
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
return attempt === 0
? input.respond("x".repeat(20_000), { status: 401 })
: input.respond("should not retry", { status: 200 })
}),
),
),
)
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error.reason.body).toHaveLength(20_000)
expect(yield* Ref.get(attempts)).toBe(1)
}),
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
expect(errorHttp(error)?.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
new Response("x".repeat(20_000), { status: 401 }),
new Response("should not retry", { status: 200 }),
]),
),
),
)
it.effect("preserves response body fields", () =>
@@ -513,12 +486,16 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
expect(errorHttp(error)?.body).toBe(
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
)
}).pipe(
Effect.provide(
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
responsesLayer([
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
status: 400,
}),
]),
),
),
)
@@ -529,10 +506,12 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error)
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
responsesLayer([
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
]),
),
),
)
@@ -540,7 +519,9 @@ describe("RequestExecutor", () => {
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -562,171 +543,19 @@ 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)
}),
)
})
describe("WebSocket channel execution", () => {
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_1" } }),
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
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("")
-2
View File
@@ -32,8 +32,6 @@ describe("public exports", () => {
expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make)
expect(TestLLM.layer).toBeFunction()
expect(TestLLM.testLayer).toBeFunction()
expect(TestLLM.Test.of).toBeFunction()
})
test("route barrel exposes route-authoring APIs", () => {
@@ -10,7 +10,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages?beta=true",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
@@ -29,7 +29,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages?beta=true",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
@@ -10,7 +10,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages?beta=true",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
@@ -29,7 +29,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages?beta=true",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
@@ -24,7 +24,7 @@
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages?beta=true",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
@@ -1,7 +1,10 @@
{
"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,7 +1,10 @@
{
"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,7 +1,10 @@
{
"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,7 +1,10 @@
{
"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"
},
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "text", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-text",
"recordedAt": "2026-08-25T23:55:27.619Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":256}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"The\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" user says: \\\"Reply exactly with: Hello!\\\" So we must output exactly\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" \\\"Hello!\\\" with no extra characters, no formatting\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\". Ensure no extra spaces or new\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"lines? Probably just \\\"Hello!\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" Usually we output exactly that.\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"content\":\"Hello!\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":142,\"completion_tokens\":58,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":46},\"prompt_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.645281,\"queue_time\":0.003817115,\"prompt_time\":0.001587193,\"completion_time\":0.029805929,\"total_time\":0.036823272705078125}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "tool", "tool-call", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-tool-call",
"recordedAt": "2026-08-25T23:55:28.454Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"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\":512}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"We\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\" need to call the function get_weather with city \\\"\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"Paris\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"},\"type\":\"function\",\"id\":\"3d860cefe\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"type\":\"function\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":174,\"completion_tokens\":37,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":13},\"prompt_tokens\":137,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.8019717,\"queue_time\":0.31196235,\"prompt_time\":0.005234764,\"completion_time\":0.020198402,\"total_time\":0.702225923538208}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"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\": 120, \"temperature\": 0}"
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"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}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
},
"response": {
"status": 200,

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