mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530af4394f | ||
|
|
5f8f439b00 | ||
|
|
f5a3769867 | ||
|
|
42e447400e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -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,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.
|
||||
@@ -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,37 +0,0 @@
|
||||
name: deploy-posts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
paths:
|
||||
- packages/posts/**
|
||||
- bun.lock
|
||||
- .github/workflows/deploy-posts.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-posts-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/posts
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/posts
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: canary # Bun 1.4 until its stable release is published
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
BUN_COMPILE_RELEASE: canary
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
@@ -195,33 +195,9 @@ jobs:
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-app-archive:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -234,7 +210,6 @@ jobs:
|
||||
host: macos-26
|
||||
- target: windows-arm64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
bun_install_flags: --cpu=*
|
||||
- target: windows-x64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
@@ -246,19 +221,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||
install-flags: --os=* --cpu=*
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache
|
||||
|
||||
- name: Build
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -573,7 +543,6 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-app-archive
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -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
|
||||
@@ -81,14 +50,6 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
if: runner.os == 'Linux'
|
||||
run: bun run test:effect-simplification-rules
|
||||
|
||||
- name: Check Effect simplifications
|
||||
if: runner.os == 'Linux'
|
||||
run: bun run lint:effect-simplifications
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
@@ -111,34 +72,9 @@ 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()
|
||||
@@ -178,7 +114,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 +125,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 +158,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 }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -1,3 +1,19 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json"
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"smoke_modal": "ctrl+alt+m",
|
||||
"smoke_screen": "ctrl+alt+o",
|
||||
"smoke_screen_home": "escape,ctrl+shift+h",
|
||||
"smoke_screen_modal": "ctrl+alt+m",
|
||||
"smoke_dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@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
@@ -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
@@ -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 = ''
|
||||
|
||||
@@ -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
@@ -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-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
|
||||
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
|
||||
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
|
||||
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
|
||||
}
|
||||
}
|
||||
|
||||
+33
-37
@@ -15,48 +15,46 @@
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"translate:app": "bun run script/translate-app.ts",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*"
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
"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-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@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",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@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 +65,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",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -84,9 +79,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 +90,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"
|
||||
@@ -132,6 +127,7 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
@@ -161,8 +157,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,14 +165,15 @@
|
||||
"@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",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@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",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -221,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
|
||||
+13
-30
@@ -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
|
||||
|
||||
@@ -327,7 +309,7 @@ Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
@@ -335,6 +317,7 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
if (response.status >= 200 && response.status < 300) return undefined
|
||||
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
|
||||
})
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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>)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))),
|
||||
})
|
||||
|
||||
|
||||
@@ -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,8 +17,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -39,17 +37,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||
}
|
||||
|
||||
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||
const omitsFunctionCallIds = (modelID: string) => {
|
||||
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -84,15 +71,10 @@ export type ProviderOptionsInput = OptionsInput
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
// Gemini is known to send explicit `null` for optional streaming fields
|
||||
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||
// so the shared schemas stay safe there.
|
||||
const GeminiTextPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
thought: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -105,11 +87,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -127,19 +109,13 @@ const GeminiContentPart = Schema.Union([
|
||||
GeminiFunctionCallPart,
|
||||
GeminiFunctionResponsePart,
|
||||
])
|
||||
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
})
|
||||
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 })),
|
||||
})
|
||||
@@ -199,48 +175,44 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: optionalNull(GeminiResponseContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
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),
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -289,41 +261,36 @@ 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) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
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
|
||||
const flushMedia = () => {
|
||||
if (!pendingMedia) return
|
||||
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
|
||||
pendingMedia = undefined
|
||||
}
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role !== "tool") flushMedia()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||
const previous = contents.at(-1)
|
||||
// Gemini rejects a continuation whose function-response turn carries extra
|
||||
// parts, so an update after a tool result starts its own user turn.
|
||||
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||
if (previous?.role === "user")
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
}
|
||||
@@ -347,19 +314,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 })
|
||||
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)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -384,7 +347,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -402,28 +365,21 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const value = ProviderShared.normalizeToolFile(item)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
content: text.join("\n"),
|
||||
},
|
||||
parts: legacyToolMedia || media.length === 0 ? undefined : media,
|
||||
parts: media.length > 0 ? media : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
flushMedia()
|
||||
return contents
|
||||
})
|
||||
|
||||
@@ -507,28 +463,24 @@ 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.
|
||||
const promptTokens = usage.promptTokenCount ?? undefined
|
||||
const cached = usage.cachedContentTokenCount ?? undefined
|
||||
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||
const visible = usage.candidatesTokenCount ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: promptTokens,
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: providerMetadata(metadataKey, usage),
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,29 +514,19 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||
// the "nothing to finish" check below keeps its meaning.
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = state.lifecycle
|
||||
if (state.reasoningSignature !== undefined)
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
)
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
@@ -593,32 +535,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)
|
||||
@@ -630,31 +556,12 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const events: LLMEvent[] = []
|
||||
let hasToolCalls = nextState.hasToolCalls
|
||||
let lifecycle = nextState.lifecycle
|
||||
let nextToolCallId = nextState.nextToolCallId
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const 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
|
||||
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.
|
||||
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
|
||||
else if (signature !== undefined && "text" in part) textSignature = signature
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
||||
reasoningSignature = part.thoughtSignature
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
lifecycle = Lifecycle.reasoningDelta(
|
||||
@@ -662,7 +569,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -670,38 +577,24 @@ 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 = undefined
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
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 +602,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -723,9 +614,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
...nextState,
|
||||
hasToolCalls,
|
||||
lifecycle,
|
||||
nextToolCallId,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
@@ -747,14 +637,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, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: (state) => Effect.succeed(finish(state)),
|
||||
onHalt: finish,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,3 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenAIResponses from "./openai-responses.js"
|
||||
export * as OpenResponses from "./open-responses.js"
|
||||
export * as OpenResponsesChannel from "./open-responses-channel.js"
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -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,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
} from "../route/transport/index.js"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
|
||||
|
||||
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
@@ -24,9 +22,12 @@ export interface Options {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly rotateAfterMs?: number
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly driver?: (input: {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
}) => WebSocketChannelDriver
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -61,8 +62,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 +75,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 +94,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") {
|
||||
@@ -113,8 +114,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
@@ -148,25 +147,18 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestParts(input)
|
||||
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
|
||||
const channel =
|
||||
input.webSocket && (options.enabled?.(parts.url) ?? true)
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: OpenResponsesContinuation.driver({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
}),
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
const channel = input.webSocket
|
||||
? yield* Effect.gen(function* () {
|
||||
const create = yield* message(parts.jsonBody)
|
||||
const base = driver(options, create.message)
|
||||
return {
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
@@ -176,37 +168,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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -33,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
@@ -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),
|
||||
@@ -167,9 +156,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -208,7 +194,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||
const OpenAIChatDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
refusal: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
@@ -218,36 +203,23 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
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 +229,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 +241,6 @@ export interface ParserState {
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
readonly nextToolIndex: number
|
||||
readonly requireFinishReason: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -283,21 +253,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 } : {}),
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
@@ -310,8 +273,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 +292,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 +332,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 +350,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 +396,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 +406,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 +422,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 +447,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,152 +495,24 @@ 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
|
||||
})
|
||||
|
||||
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||
// whenever the conversation history contains tool calls/results. Send an
|
||||
// explicit empty array when we have history but no active tools.
|
||||
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool") return true
|
||||
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
}
|
||||
@@ -736,39 +530,23 @@ 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 hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = request.tools.length > 0
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
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 +556,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,53 +566,25 @@ 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
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], 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 ?? 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 +596,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 +670,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 +679,24 @@ 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 usage = mapUsage(event.usage) ?? state.usage
|
||||
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 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
|
||||
@@ -971,17 +709,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||
const hasLateContent =
|
||||
Boolean(delta?.content) ||
|
||||
Boolean(delta?.refusal) ||
|
||||
reasoning !== undefined ||
|
||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||
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,23 +722,26 @@ 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 (
|
||||
reasoningDetailsObserved &&
|
||||
!lifecycle.reasoning.has("reasoning-0") &&
|
||||
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
|
||||
(Boolean(delta?.content) || toolDeltas.length > 0)
|
||||
)
|
||||
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?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
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)
|
||||
}
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1040,26 +776,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 +794,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
return [
|
||||
{
|
||||
providerMetadataKey: state.providerMetadataKey,
|
||||
tools: finished?.tools ?? tools,
|
||||
pendingTools,
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
@@ -1083,25 +806,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 +824,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 +856,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 +867,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"
|
||||
|
||||
@@ -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,13 +104,13 @@ 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)
|
||||
return Effect.undefined
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
return Effect.succeed(undefined)
|
||||
})
|
||||
const multipartMask =
|
||||
mask === undefined
|
||||
@@ -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:
|
||||
|
||||
+12
-18
@@ -1,10 +1,10 @@
|
||||
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"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const PROTOCOL = "openai-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
@@ -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,16 +154,11 @@ 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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
export const OpenAIResponsesChannel = { driver } as const
|
||||
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { LLMEvent, 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 { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -32,40 +33,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([
|
||||
@@ -73,9 +40,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -89,7 +65,16 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
type: "input_file",
|
||||
filename: part.filename ?? "document.pdf",
|
||||
file_data: media.base64,
|
||||
mime_type: media.mime,
|
||||
}
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -122,18 +107,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 +123,53 @@ 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) {
|
||||
type HostedToolData = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
|
||||
|
||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
||||
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
||||
|
||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
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 {
|
||||
@@ -168,31 +186,44 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
item: HostedToolItem,
|
||||
) {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
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: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
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)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
@@ -214,12 +245,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
|
||||
export const transport = channelTransport({
|
||||
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
||||
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
@@ -230,7 +261,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"
|
||||
|
||||
@@ -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,
|
||||
@@ -25,17 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||
export const 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
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming tool-call accumulator. Adapters that build a tool call across
|
||||
* multiple `tool-input-delta` chunks store the partial JSON input string here
|
||||
@@ -97,15 +85,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,81 +197,32 @@ 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, 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> =>
|
||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): 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.filter(
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.pipeThroughChannel(Sse.decode()),
|
||||
Stream.catchTag("Retry", () => Stream.empty),
|
||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 +269,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
|
||||
if (!url.startsWith("data:")) return Effect.succeed(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 })),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,15 +21,9 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
@@ -62,31 +56,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 => {
|
||||
|
||||
@@ -28,10 +28,7 @@ export const ResponseIncludables = [
|
||||
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 type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -41,7 +38,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
@@ -50,16 +47,9 @@ export const AllowedTools = Schema.Struct({
|
||||
})
|
||||
export type AllowedTools = typeof AllowedTools.Type
|
||||
|
||||
export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
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),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -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
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { Lifecycle } from "./lifecycle.js"
|
||||
|
||||
export type Item = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly input: (item: Item) => unknown
|
||||
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
|
||||
}
|
||||
|
||||
export type Definitions = Readonly<Record<string, Definition>>
|
||||
|
||||
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
|
||||
item.type in tools && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
export const onDone: (
|
||||
state: OpenResponses.ParserState,
|
||||
item: Item,
|
||||
tools: Definitions,
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(
|
||||
function* (state, item, tools) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: tool.result
|
||||
? yield* tool.result(item)
|
||||
: item.error !== undefined && item.error !== null
|
||||
? { type: "error", value: item.error }
|
||||
: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
},
|
||||
)
|
||||
|
||||
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
|
||||
@@ -2,6 +2,20 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
||||
|
||||
const removeNullSchemas = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(removeNullSchemas)
|
||||
if (!isRecord(value)) return value
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => key !== "anyOf")
|
||||
.map(([key, field]) => [key, removeNullSchemas(field)]),
|
||||
)
|
||||
if (!Array.isArray(value.anyOf)) return fields
|
||||
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
|
||||
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
|
||||
return { ...fields, anyOf: variants }
|
||||
}
|
||||
|
||||
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
||||
const projected = items.map(moonshotNode)
|
||||
if (projected.length === 0) return {}
|
||||
@@ -31,7 +45,24 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
|
||||
return isRecord(projected) ? projected : {}
|
||||
}
|
||||
|
||||
const openAI = (schema: JsonSchema): JsonSchema => schema
|
||||
const openAI = (schema: JsonSchema): JsonSchema => {
|
||||
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
|
||||
const flattened =
|
||||
variants.length === 0
|
||||
? { ...schema, type: "object" }
|
||||
: {
|
||||
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
|
||||
type: "object",
|
||||
properties: variants.reduce(
|
||||
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
|
||||
{},
|
||||
),
|
||||
additionalProperties: false,
|
||||
}
|
||||
const normalized = removeNullSchemas(flattened)
|
||||
return isRecord(normalized) ? normalized : { type: "object" }
|
||||
}
|
||||
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,87 +0,0 @@
|
||||
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 ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
|
||||
// handled by the baseline); only its hosted tool vocabulary differs.
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
schema: XAIResponsesBody,
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
@@ -80,87 +78,85 @@ const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | 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 code?: string | undefined
|
||||
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(),
|
||||
)
|
||||
// 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 body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const text = body || input.message
|
||||
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 (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> {
|
||||
|
||||
@@ -4,6 +4,11 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly input?: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Definition<
|
||||
|
||||
@@ -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,9 +88,9 @@ 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 } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
@@ -112,4 +104,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),
|
||||
})
|
||||
@@ -63,6 +62,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -68,6 +68,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -63,6 +63,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
@@ -11,7 +10,6 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
|
||||
|
||||
export const id = ProviderID.make("azure")
|
||||
const routeAuth = Auth.remove("authorization")
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
|
||||
// Azure needs the customer's resource URL; supply either `resourceName`
|
||||
// (helper builds the URL) or `baseURL` directly.
|
||||
@@ -42,30 +40,6 @@ const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "azure-openai-responses",
|
||||
name: "Azure OpenAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
enabled: (value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname.endsWith(".openai.azure.com") &&
|
||||
url.pathname.endsWith("/openai/v1/responses") &&
|
||||
url.searchParams.get("api-version") === "v1"
|
||||
)
|
||||
},
|
||||
url: (value) => {
|
||||
const url = new URL(value)
|
||||
url.searchParams.delete("api-version")
|
||||
return url.toString()
|
||||
},
|
||||
headers: (headers) => {
|
||||
const apiKey = headers["api-key"]
|
||||
if (!apiKey) return headers
|
||||
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
@@ -152,6 +126,7 @@ const config = (settings: Settings): Config => {
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -76,6 +75,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
@@ -13,7 +14,6 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
|
||||
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
const HEADER_VERSION = "2023-06-01" as const
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -57,8 +57,7 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
@@ -111,6 +110,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -81,6 +80,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, contents, labels }
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
@@ -68,7 +58,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)
|
||||
@@ -129,6 +119,7 @@ export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -63,6 +63,7 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -55,6 +55,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -74,6 +74,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ const config = (settings: Settings): Config => {
|
||||
baseURL: settings.baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -118,6 +119,7 @@ export const protocol = Protocol.make({
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
@@ -163,10 +165,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]
|
||||
@@ -199,5 +200,6 @@ export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptio
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -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)
|
||||
@@ -5,8 +5,7 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
@@ -29,20 +28,14 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
}),
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false } },
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
@@ -108,6 +101,7 @@ export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const responses = provider.responses
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,19 +7,18 @@ 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,
|
||||
LanguageModelLimits,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
InvalidProviderOutputReason,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -75,6 +74,7 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
|
||||
|
||||
export interface RouteDefaults {
|
||||
readonly headers?: Record<string, string>
|
||||
readonly limits?: LanguageModelLimits
|
||||
readonly generation?: GenerationOptions
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions
|
||||
@@ -82,6 +82,7 @@ export interface RouteDefaults {
|
||||
|
||||
export interface RouteDefaultsInput {
|
||||
readonly headers?: Record<string, string>
|
||||
readonly limits?: LanguageModelLimits.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -90,7 +91,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>
|
||||
@@ -119,6 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
|
||||
...base,
|
||||
...patch,
|
||||
headers,
|
||||
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
|
||||
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
||||
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
||||
http: mergeHttpOptions(
|
||||
@@ -228,14 +229,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 +267,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 +292,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 +321,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 +386,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,23 +19,9 @@ 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",
|
||||
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
|
||||
})
|
||||
|
||||
export * as Framing from "./framing.js"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,48 @@ 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,
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
override get message(): string {
|
||||
return this.reason.message
|
||||
override get message() {
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ export const MediaPart = Schema.Struct({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
filename: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
@@ -256,7 +255,6 @@ export namespace ToolDefinition {
|
||||
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
|
||||
type: Schema.Literals(["auto", "none", "required", "tool"]),
|
||||
name: Schema.optional(Schema.String),
|
||||
disableParallelToolUse: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace ToolChoice {
|
||||
|
||||
@@ -114,7 +114,22 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelLimits {
|
||||
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
|
||||
|
||||
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
|
||||
export const make = (input: Input | undefined) =>
|
||||
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
|
||||
}
|
||||
|
||||
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
|
||||
limits: Schema.optional(LanguageModelLimits),
|
||||
generation: Schema.optional(GenerationOptions),
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
@@ -124,6 +139,7 @@ export namespace LanguageModelDefaults {
|
||||
export type Input =
|
||||
| LanguageModelDefaults
|
||||
| {
|
||||
readonly limits?: LanguageModelLimits.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -133,6 +149,7 @@ export namespace LanguageModelDefaults {
|
||||
export const make = (input: Input) => {
|
||||
if (input instanceof LanguageModelDefaults) return input
|
||||
return new LanguageModelDefaults({
|
||||
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
|
||||
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
|
||||
providerOptions: input.providerOptions,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
@@ -153,16 +170,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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
@@ -337,20 +270,21 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the Anthropic default before call maxTokens", () =>
|
||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const route = AnthropicMessages.route.with({
|
||||
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
limits: { output: 128 },
|
||||
})
|
||||
const model = route.model({ id: "claude-sonnet-4-5" })
|
||||
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
|
||||
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
||||
const withMaxTokens = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
|
||||
)
|
||||
|
||||
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
|
||||
expect(withMaxTokens.body.max_tokens).toBe(8_000)
|
||||
expect(withoutMaxTokens.body.max_tokens).toBe(64)
|
||||
expect(withMaxTokens.body.max_tokens).toBe(32)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+201
-372
@@ -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("")
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user