Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton 3d2d43e8c4 perf(tui): tighten markdown demo rendering 2026-05-10 14:01:38 -04:00
Kit Langton 9c354995a5 style(tui): polish markdown tables and quotes 2026-05-10 13:51:00 -04:00
Kit Langton 88db6d22f8 style(tui): refine markdown block rendering 2026-05-10 13:40:48 -04:00
Kit Langton 13b2917f74 feat(tui): add fullscreen markdown demo 2026-05-10 13:28:00 -04:00
Kit Langton 5469155eed refactor(session): introduce timeline rewind service 2026-05-10 12:20:46 -04:00
331 changed files with 11598 additions and 15705 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ runs:
fi
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
uses: oven-sh/setup-bun@v2
with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
@@ -34,7 +34,7 @@ runs:
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
@@ -19,7 +19,7 @@ runs:
steps:
- name: Create app token
id: apptoken
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
uses: actions/create-github-app-token@v2
with:
app-id: ${{ inputs.opencode-app-id }}
private-key: ${{ inputs.opencode-app-secret }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 0
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
contents: read
issues: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 2 hours
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
+4 -4
View File
@@ -21,18 +21,18 @@ jobs:
REGISTRY: ghcr.io/${{ github.repository_owner }}
TAG: "24.04"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-bun
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+2 -2
View File
@@ -13,11 +13,11 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: "24"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history to access commits
@@ -43,7 +43,7 @@ jobs:
- name: Run opencode
if: steps.commits.outputs.has_commits == 'true'
uses: sst/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -125,7 +125,7 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
+2 -2
View File
@@ -20,10 +20,10 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
+5 -5
View File
@@ -41,10 +41,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
uses: nixbuild/nix-quick-install-action@v34
- name: Compute node_modules hash
id: hash
@@ -72,7 +72,7 @@ jobs:
echo "Computed hash for ${SYSTEM}: $HASH"
- name: Upload hash
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: hash-${{ matrix.system }}
path: hash.txt
@@ -85,7 +85,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
@@ -102,7 +102,7 @@ jobs:
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
- name: Download hash artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
path: hashes
pattern: hash-*
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Send nicely-formatted embed to Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
uses: SethCohen/github-releases-to-discord@v1
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
+2 -2
View File
@@ -21,12 +21,12 @@ jobs:
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- uses: ./.github/actions/setup-bun
- name: Run opencode
uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -78,7 +78,7 @@ jobs:
issues: write
steps:
- name: Add Contributor Label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@v8
with:
script: |
const isPR = !!context.payload.pull_request;
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- name: Check PR standards
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
@@ -159,7 +159,7 @@ jobs:
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
with:
fetch-depth: 0
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
with:
fetch-depth: 0
+26 -26
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -72,7 +72,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
with:
fetch-tags: true
@@ -95,14 +95,14 @@ jobs:
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
@@ -123,9 +123,9 @@ jobs:
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
@@ -138,13 +138,13 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
@@ -201,7 +201,7 @@ jobs:
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
@@ -249,9 +249,9 @@ jobs:
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
- uses: apple-actions/import-codesign-certs@v2
if: runner.os == 'macOS'
with:
keychain: build
@@ -268,19 +268,19 @@ jobs:
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
@@ -388,12 +388,12 @@ jobs:
}
}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
with:
name: opencode-desktop-${{ matrix.settings.target }}
path: packages/desktop/dist/*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- uses: actions/upload-artifact@v4
if: needs.version.outputs.release
with:
name: latest-yml-${{ matrix.settings.target }}
@@ -408,44 +408,44 @@ jobs:
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-bun
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
pattern: latest-yml-*
@@ -459,7 +459,7 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Cache apt packages (AUR)
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
with:
fetch-depth: 0
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
name: Release Zed Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
with:
fetch-depth: 0
+9 -9
View File
@@ -37,12 +37,12 @@ jobs:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: "24"
@@ -55,7 +55,7 @@ jobs:
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
@@ -75,7 +75,7 @@ jobs:
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
@@ -85,7 +85,7 @@ jobs:
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
@@ -111,12 +111,12 @@ jobs:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: "24"
@@ -131,7 +131,7 @@ jobs:
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
@@ -155,7 +155,7 @@ jobs:
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
+30 -24
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,15 +147,17 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@hono/zod-validator": "catalog:",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"hono": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -169,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -193,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.48",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -227,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -281,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -311,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -327,7 +329,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -340,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -358,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.48",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -494,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -532,7 +534,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -547,7 +549,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -582,7 +584,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -631,7 +633,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -684,8 +686,8 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@effect/opentelemetry": "4.0.0-beta.57",
"@effect/platform-node": "4.0.0-beta.57",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
@@ -718,7 +720,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.65",
"effect": "4.0.0-beta.59",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1079,11 +1081,11 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.57", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.57" } }, "sha512-C976X6f+qHUtLSqcqImuCrjhAHnJV17NC2RvvybsAuDfkyIWU4MyiO2XwgiBeijeNupyr1M/KPKnyjtkNxV9Hw=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1233,6 +1235,8 @@
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
"@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="],
@@ -3037,7 +3041,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5465,6 +5469,8 @@
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+24 -38
View File
@@ -1,8 +1,6 @@
import { SECRET } from "./secret"
import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`,
@@ -27,16 +25,6 @@ const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
],
})
// Honeycomb can keep stale query-local calculated fields when the name is unchanged,
// so tie the field name to the expression while avoiding deploy-to-deploy churn.
// https://github.com/honeycombio/terraform-provider-honeycombio/issues/852
const calculatedField = (field: { name: string; expression: string }) => ({
...field,
name: `${field.name}_${(
Array.from(field.expression).reduce((result, char) => Math.imul(31, result) + char.charCodeAt(0), 0) >>> 0
).toString(36)}`,
})
const modelHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [
{ column: "model", op: "exists" },
@@ -44,26 +32,21 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => {
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
const failedHttpStatus = calculatedField({
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
})
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"],
calculatedFields: [failedHttpStatus],
calculatedFields: [
{
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
},
],
calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
{
op: "SUM",
name: "FAILED",
column: failedHttpStatus.name,
filterCombination: "AND",
filters,
},
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
@@ -76,30 +59,31 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
const successHttpStatus = calculatedField({
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
})
const failedProviderHttpStatus = calculatedField({
name: "is_failed_provider_http_status",
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
})
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"],
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
calculatedFields: [
{
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
},
{
name: "is_failed_provider_http_status",
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
},
],
calculations: [
{
op: "SUM",
name: "SUCCESS",
column: successHttpStatus.name,
column: "is_success_http_status",
filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
},
{
op: "SUM",
name: "FAILED",
column: failedProviderHttpStatus.name,
column: "is_failed_provider_http_status",
filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
},
@@ -111,6 +95,8 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
}).json
}
const description = "Managed by SST (Don't edit in Honeycomb UI)"
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]",
description,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q9r1S15YL9LQK7DRhuOpw3Fxi24BPovEM995GZJayKw=",
"aarch64-linux": "sha256-C0rRTLnxxuuEkCBc3JZbkR66TUVwpcPFif3BU9GRAuA=",
"aarch64-darwin": "sha256-1HvalOO/pOkRlYH8CZ93psapt90C+pYzui1JCadBE1Q=",
"x86_64-darwin": "sha256-RrndyLWfhWm4mZ88XytFF2NI+ly8la550Z5LBN/g5u4="
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
}
}
+5 -3
View File
@@ -7,6 +7,8 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts --demo",
"dev:run-demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts run --interactive --demo",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -28,8 +30,8 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@effect/opentelemetry": "4.0.0-beta.57",
"@effect/platform-node": "4.0.0-beta.57",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
@@ -55,7 +57,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.65",
"effect": "4.0.0-beta.59",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.46",
"description": "",
"type": "module",
"exports": {
@@ -231,7 +231,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
children[key] = child
disposers.set(key, dispose)
@@ -81,7 +81,6 @@ const baseState = (input: Partial<State> = {}) =>
limit: 10,
message: {},
part: {},
part_text_accum_delta: {},
...input,
}) as State
@@ -211,12 +211,6 @@ export function applyDirectoryEvent(input: {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
for (const part of parts) {
delete draft.part_text_accum_delta[part.id]
}
}
delete draft.part[props.messageID]
}),
)
@@ -225,11 +219,6 @@ export function applyDirectoryEvent(input: {
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[part.id]
}),
)
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])
@@ -251,11 +240,6 @@ export function applyDirectoryEvent(input: {
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
}),
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
@@ -279,7 +263,6 @@ export function applyDirectoryEvent(input: {
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
input.setStore(
"part",
props.messageID,
@@ -39,7 +39,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
@@ -48,14 +47,12 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
@@ -73,7 +70,6 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
session_diff: {},
@@ -82,7 +78,6 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
part_text_accum_delta: {},
}
dropSessionCaches(store, ["ses_1"])
@@ -18,7 +18,6 @@ type SessionCache = {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
@@ -28,9 +27,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
for (const part of parts) {
delete store.part_text_accum_delta[part.id]
}
delete store.part[key]
}
@@ -72,9 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
part_text_accum_delta: {
[partID: string]: string
}
}
export type VcsCache = {
-13
View File
@@ -43,7 +43,6 @@ type SessionView = {
reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
}
type TabHandoff = {
@@ -760,18 +759,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(key(), tab, pos)
},
todoCollapsed: {
get: () => s().todoCollapsed ?? false,
set(collapsed: boolean) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
} else {
setStore("sessionView", session, "todoCollapsed", collapsed)
}
},
},
terminal: {
opened: terminalOpened,
open() {
+1 -1
View File
@@ -1934,7 +1934,7 @@ export default function Layout(props: ParentProps) {
if (!created?.directory) return
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
setWorkspaceName(created.directory, created.branch, project.id, created.branch)
const local = project.worktree
const key = pathKey(created.directory)
@@ -2,7 +2,6 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useLayout } from "@/context/layout"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -47,12 +46,10 @@ export function SessionComposerRegion(props: {
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
const layout = useLayout()
const prompt = usePrompt()
const language = useLanguage()
const route = useSessionKey()
const sync = useSync()
const view = layout.view(route.sessionKey)
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
@@ -210,8 +207,6 @@ export function SessionComposerRegion(props: {
<SessionTodoDock
sessionID={route.params.id}
todos={props.state.todos()}
collapsed={view.todoCollapsed.get()}
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
@@ -42,17 +42,18 @@ function dot(status: Todo["status"]) {
export function SessionTodoDock(props: {
sessionID?: string
todos: Todo[]
collapsed: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
dockProgress: number
}) {
const language = useLanguage()
const [store, setStore] = createStore({
collapsed: false,
height: 320,
})
const toggle = () => setStore("collapsed", (value) => !value)
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
@@ -71,7 +72,7 @@ export function SessionTodoDock(props: {
)
const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
@@ -106,11 +107,11 @@ export function SessionTodoDock(props: {
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={props.onToggle}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
toggle()
}}
>
<span
@@ -147,7 +148,7 @@ export function SessionTodoDock(props: {
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={props.collapsed ? preview() : undefined}
text={store.collapsed ? preview() : undefined}
duration={600}
travel={25}
edge={17}
@@ -160,7 +161,7 @@ export function SessionTodoDock(props: {
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
@@ -171,16 +172,16 @@ export function SessionTodoDock(props: {
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
toggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div
data-slot="session-todo-list"
aria-hidden={props.collapsed || off()}
aria-hidden={store.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
@@ -67,7 +67,6 @@
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
background: transparent;
border: none;
@@ -80,7 +79,6 @@
}
svg {
flex-shrink: 0;
width: 16px;
height: 16px;
}
@@ -53,7 +53,7 @@ export function UsageSection() {
}
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens
return u.outputTokens + (u.reasoningTokens ?? 0)
}
const goPrev = async () => {
@@ -47,7 +47,6 @@ import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -130,8 +129,6 @@ export async function handler(
logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
const modelTpmLimits = await modelTpmLimiter?.check()
const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers)
const modelTpsLimits = await modelTpsLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@@ -145,7 +142,6 @@ export async function handler(
retry,
stickyProvider,
modelTpmLimits,
modelTpsLimits,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@@ -298,17 +294,14 @@ export async function handler(
let buffer = ""
let responseLength = 0
let timestampFirstByte = 0
let timestampLastByte = 0
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value: rawValue }) => {
if (done) {
const timestampLastByte = Date.now()
logger.metric({
response_length: responseLength,
"timestamp.last_byte": timestampLastByte,
"timestamp.last_byte": Date.now(),
})
dataDumper?.flush()
await rateLimiter?.track()
@@ -318,13 +311,6 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await modelTpsLimiter?.track(
providerInfo.id,
providerInfo.model,
timestampFirstByte,
timestampLastByte,
usageInfo,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@@ -335,10 +321,10 @@ export async function handler(
}
if (responseLength === 0) {
timestampFirstByte = Date.now()
const now = Date.now()
logger.metric({
time_to_first_byte: timestampFirstByte - startTimestamp,
"timestamp.first_byte": timestampFirstByte,
time_to_first_byte: now - startTimestamp,
"timestamp.first_byte": now,
})
}
@@ -492,7 +478,6 @@ export async function handler(
retry: RetryOptions,
stickyProvider: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, boolean> | undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
@@ -524,11 +509,6 @@ export async function handler(
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}`] ?? false
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
@@ -909,6 +889,10 @@ export async function handler(
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
@@ -925,11 +909,17 @@ export async function handler(
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
return {
totalCostInCent,
inputCost,
outputCost,
reasoningCost,
cacheReadCost,
cacheWrite5mCost,
cacheWrite1hCost,
@@ -951,7 +941,8 @@ export async function handler(
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
usageInfo
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
costInfo
logger.metric({
"tokens.input": inputTokens,
@@ -962,12 +953,14 @@ export async function handler(
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
@@ -1,89 +0,0 @@
import { and, Database, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createModelTpsLimiter(providers: { id: string; model: string; tpsGoal?: number }[]) {
const tpsGoals = Object.fromEntries(
providers.flatMap((p) => {
return p.tpsGoal ? [[`${p.id}/${p.model}`, p.tpsGoal]] : []
}),
)
const ids = Object.keys(tpsGoals)
if (ids.length === 0) return
const toInterval = (date: Date) =>
parseInt(
date
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12),
)
const now = Date.now()
const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60 * 1000))
return {
check: async () => {
const data = await Database.use((tx) =>
tx
.select()
.from(ModelTpsRateLimitTable)
.where(
and(
inArray(ModelTpsRateLimitTable.id, ids),
inArray(ModelTpsRateLimitTable.interval, [currInterval, prevInterval]),
),
),
)
// convert to map of model to summed count across current and previous intervals
const result = data.reduce(
(acc, curr) => {
const existing = acc[curr.id] ?? { qualify: 0, unqualify: 0 }
acc[curr.id] = {
qualify: existing.qualify + curr.qualify,
unqualify: existing.unqualify + curr.unqualify,
}
return acc
},
{} as Record<string, { qualify: number; unqualify: number }>,
)
return Object.fromEntries(
Object.entries(result).map(([id, { qualify, unqualify }]) => {
const isLowTps = qualify + unqualify > 10 && qualify < unqualify
return [id, isLowTps]
}),
)
},
track: async (provider: string, model: string, tsFirstByte: number, tsLastByte: number, usageInfo: UsageInfo) => {
const id = `${provider}/${model}`
if (!ids.includes(id)) return
const tpsGoal = tpsGoals[id]
if (!tpsGoal) return
if (tsFirstByte <= 0 || tsLastByte <= 0) return
const tokens = usageInfo.outputTokens
if (tokens <= 10) return
const tps = (tokens / (tsLastByte - tsFirstByte)) * 1000
const qualify = tps >= tpsGoal ? 1 : 0
const unqualify = tps < tpsGoal ? 1 : 0
await Database.use((tx) =>
tx
.insert(ModelTpsRateLimitTable)
.values({
id,
interval: currInterval,
qualify,
unqualify,
})
.onDuplicateKeyUpdate({
set: {
qualify: sql`${ModelTpsRateLimitTable.qualify} + ${qualify}`,
unqualify: sql`${ModelTpsRateLimitTable.unqualify} + ${unqualify}`,
},
}),
)
},
}
}
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
@@ -1,7 +0,0 @@
CREATE TABLE `model_tps_rate_limit` (
`id` varchar(255) NOT NULL,
`interval` bigint NOT NULL,
`qualify` int NOT NULL,
`unqualify` int NOT NULL,
CONSTRAINT PRIMARY KEY(`id`,`interval`)
);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -36,7 +36,6 @@ export namespace ZenData {
model: z.string(),
priority: z.number().optional(),
tpmLimit: z.number().optional(),
tpsGoal: z.number().optional(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
@@ -40,14 +40,3 @@ export const ModelTpmRateLimitTable = mysqlTable(
},
(table) => [primaryKey({ columns: [table.id, table.interval] })],
)
export const ModelTpsRateLimitTable = mysqlTable(
"model_tps_rate_limit",
{
id: varchar("id", { length: 255 }).notNull(),
interval: bigint("interval", { mode: "number" }).notNull(),
qualify: int("qualify").notNull(),
unqualify: int("unqualify").notNull(),
},
(table) => [primaryKey({ columns: [table.id, table.interval] })],
)
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -20,10 +20,12 @@
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@hono/zod-validator": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"hono": "catalog:",
"zod": "catalog:"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.46",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+370
View File
@@ -0,0 +1,370 @@
import { Effect, Option, Schema, SchemaAST } from "effect"
import z from "zod"
/**
* Annotation key for providing a hand-crafted Zod schema that the walker
* should use instead of re-deriving from the AST. Attach it via
* `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`.
*/
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
// AST nodes are immutable and frequently shared across schemas (e.g. a single
// Schema.Class embedded in multiple parents). Memoizing by node identity
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
// by reference across callers.
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
// Shared empty ParseOptions for the rare callers that need one — avoids
// allocating a fresh object per parse inside refinements and transforms.
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
/**
* Derive a Zod value from an Effect Schema (or a Schema-backed export with a
* `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
* `.omit`, `.extend`, and friends are accessible.
*
* The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
* to an object; this helper keeps the "I started from a `Schema.Struct`" cast
* in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
* call sites.
*
* The return is intentionally loose — carrying Schema field types through the
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
* explosions for branded primitives (`string & Brand<"SessionID">` extends
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
*/
export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
return derived as unknown as z.ZodObject<any>
}
function isZodType(value: unknown): value is z.ZodTypeAny {
return typeof value === "object" && value !== null && "_zod" in value
}
/**
* Emit a JSON Schema for a tool/route parameter schema — derives the zod form
* via the walker so Effect Schema inputs flow through the same zod-openapi
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
*/
export function toJsonSchema<S extends Schema.Top>(schema: S) {
return z.toJSONSchema(zod(schema), { io: "input" })
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const cached = walkCache.get(ast)
if (cached) return cached
const result = walkUncached(ast)
walkCache.set(ast, result)
return result
}
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
// `description` annotations layer on top of an override so callers can
// reuse a shared override schema (e.g. `SessionID`) and still add a
// per-field description on the outer wrapper.
const base = override ?? bodyWithChecks(ast)
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const described = desc ? base.describe(desc) : base
return ref ? described.meta({ ref }) : described
}
function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
// constructs the class instance. For the Zod derivation we want the plain
// field shape (the decoded/consumer view), not the class instance — so
// Declarations fall through to body(), not encoded(). User-level
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
//
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
// encoding resolves a default from Option.none() route through body()/opt().
const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
const base = hasTransform ? encoded(ast) : body(ast)
return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
}
// Walk the encoded side and apply each link's decode to produce the decoded
// shape. A node `Target` produced by `from.decodeTo(Target)` carries
// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
// nest the encoding via `Link.to` so walking it recursively threads all
// prior transforms — typical encoding.length is 1.
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
const encoding = ast.encoding!
return encoding.reduce<z.ZodTypeAny>(
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
walk(encoding[0].to),
)
}
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
// decodeTo case) resolve synchronously, so running with no services is safe.
// Effectful / middleware-based transforms will surface as Effect defects.
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
const exit = Effect.runSyncExit(
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
Option.Option<unknown>
>,
)
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
return Option.getOrElse(exit.value, () => value)
}
// Flatten FilterGroups and any nested variants into a linear list of Filters.
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
// translated into native Zod methods so their JSON Schema output includes
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
// Anything else falls back to a single .superRefine layer — runtime-only,
// emits no JSON Schema constraint.
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
const filters: SchemaAST.Filter<unknown>[] = []
const collect = (c: SchemaAST.Check<unknown>) => {
if (c._tag === "FilterGroup") c.checks.forEach(collect)
else filters.push(c)
}
checks.forEach(collect)
const unhandled: SchemaAST.Filter<unknown>[] = []
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
const next = translateFilter(acc, filter)
if (next) return next
unhandled.push(filter)
return acc
}, out)
if (unhandled.length === 0) return translated
return translated.superRefine((value, ctx) => {
for (const filter of unhandled) {
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
if (!issue) continue
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
}
})
}
// Translate a well-known Effect Schema filter into a native Zod method call on
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
// construction time. Returns `undefined` for unrecognised filters so the
// caller can fall back to the generic .superRefine path.
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
if (!meta || typeof meta._tag !== "string") return undefined
switch (meta._tag) {
case "isInt":
return call(out, "int")
case "isFinite":
return call(out, "finite")
case "isGreaterThan":
return call(out, "gt", meta.exclusiveMinimum)
case "isGreaterThanOrEqualTo":
return call(out, "gte", meta.minimum)
case "isLessThan":
return call(out, "lt", meta.exclusiveMaximum)
case "isLessThanOrEqualTo":
return call(out, "lte", meta.maximum)
case "isBetween": {
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
if (!lo) return undefined
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
}
case "isMultipleOf":
return call(out, "multipleOf", meta.divisor)
case "isMinLength":
return call(out, "min", meta.minLength)
case "isMaxLength":
return call(out, "max", meta.maxLength)
case "isLengthBetween": {
const lo = call(out, "min", meta.minimum)
if (!lo) return undefined
return call(lo, "max", meta.maximum)
}
case "isPattern":
return call(out, "regex", meta.regExp)
case "isStartsWith":
return call(out, "startsWith", meta.startsWith)
case "isEndsWith":
return call(out, "endsWith", meta.endsWith)
case "isIncludes":
return call(out, "includes", meta.includes)
case "isUUID":
return call(out, "uuid")
case "isULID":
return call(out, "ulid")
case "isBase64":
return call(out, "base64")
case "isBase64Url":
return call(out, "base64url")
}
return undefined
}
// Invoke a named Zod method on `target` if it exists, otherwise return
// undefined so the caller can fall back. Using this helper instead of a
// typed cast keeps `translateFilter` free of per-case narrowing noise.
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
return typeof fn === "function" ? fn.apply(target, args) : undefined
}
function issueMessage(issue: any): string | undefined {
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
if (typeof issue?.message === "string") return issue.message
return undefined
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast)
switch (ast._tag) {
case "String":
return z.string()
case "Number":
return z.number()
case "Boolean":
return z.boolean()
case "Null":
return z.null()
case "Undefined":
return z.undefined()
case "Any":
case "Unknown":
return z.unknown()
case "Never":
return z.never()
case "Literal":
return z.literal(ast.literal)
case "Union":
return union(ast)
case "Objects":
return object(ast)
case "Arrays":
return array(ast)
case "Declaration":
return decl(ast)
default:
return fail(ast)
}
}
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
const inner =
items.length === 1
? walk(items[0])
: items.length > 1
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
: z.undefined()
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
// it to extract the default and emit `.default(...)` instead of `.optional()`.
const fallback = extractDefault(ast)
if (fallback !== undefined) return inner.default(fallback.value)
return inner.optional()
}
type DecodeLink = {
readonly transformation: {
readonly decode: {
readonly run: (
input: Option.Option<unknown>,
options: SchemaAST.ParseOptions,
) => Effect.Effect<Option.Option<unknown>, unknown>
}
}
}
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
if (!encoding?.length) return undefined
// Walk the chain of encoding Links in order; the first Getter that produces
// a value from Option.none wins. withDecodingDefault always puts its
// defaulting Link adjacent to the optional Union.
for (const link of encoding) {
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
if (probe._tag !== "Success") continue
if (Option.isSome(probe.value)) return { value: probe.value.value }
}
return undefined
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
// When every member is a string literal, emit z.enum() so that
// JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }.
if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) {
return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]])
}
const items = ast.types.map(walk)
if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast)
const discriminator = ast.annotations?.discriminator
if (typeof discriminator === "string") {
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
}
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
// Pure record: { [k: string]: V }
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type))
}
// Pure object with known fields and no index signatures.
if (ast.indexSignatures.length === 0) {
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
}
// Struct with a catchall (StructWithRest): known fields + index signature.
// Only supports a single string-keyed index signature; multi-signature or
// symbol/number keys fall through to fail.
if (ast.indexSignatures.length !== 1) return fail(ast)
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
.catchall(walk(sig.type))
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
// Pure variadic arrays: { elements: [], rest: [item] }
if (ast.elements.length === 0) {
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
// Tuples with a variadic tail (...rest) are not yet supported.
if (ast.rest.length > 0) return fail(ast)
const items = ast.elements.map(walk)
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
if (ast.typeParameters.length !== 1) return fail(ast)
return walk(ast.typeParameters[0])
}
function fail(ast: SchemaAST.AST): never {
const ref = SchemaAST.resolveIdentifier(ast)
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -10,6 +11,13 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels where new experiments default to ON (unstable / internal users).
// Stable channels (`prod`, `latest`) stay opt-in.
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
function unstableDefault(key: string) {
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -48,6 +56,9 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
// Default-on for dev/beta/local; opt-in for stable. Set
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
@@ -85,7 +96,6 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+2
View File
@@ -1,4 +1,5 @@
import { Option, Schema, SchemaGetter } from "effect"
import { zod, ZodOverride } from "./effect-zod"
/**
* Integer greater than zero.
@@ -20,6 +21,7 @@ export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
decode: SchemaGetter.passthrough({ strict: false }),
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
}),
Schema.annotate({ [ZodOverride]: zod(schema).optional() }),
)
/**
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.48",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.48"
version = "1.14.46"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+40 -37
View File
@@ -16,9 +16,8 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
## Quickstart
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
By default the layer records on first run and replays on subsequent runs —
no env-var ternary at the call site, and `CI=true` forces strict replay so
missing cassettes fail loudly in CI rather than silently re-recording.
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
runs replay from it.
```ts
import { Effect } from "effect"
@@ -31,22 +30,28 @@ const program = Effect.gen(function* () {
return yield* response.json
})
// Records if the cassette is missing, replays if it exists.
// In CI (CI=true) always replays — fails loudly on missing fixtures.
// Replay (default). Fails if the cassette is missing.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
// Force a refresh — always hits upstream and overwrites.
// Record. Hits the upstream and writes the cassette.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
```
Set the mode from the environment in your test setup:
```ts
HttpRecorder.cassetteLayer("users/get-one", {
mode: process.env.RECORD === "true" ? "record" : "replay",
})
```
## Modes
| Mode | Behavior |
| ------------- | ----------------------------------------------------------------------------------- |
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
| `replay` | Strict — match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
| Mode | Behavior |
| ------------- | -------------------------------------------------------------------- |
| `replay` | Default. Match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
## Cassette format
@@ -70,15 +75,19 @@ Cassettes are normal source files — review them, diff them, commit them.
## Request matching
Replay walks the cassette in record order via an internal cursor: the Nth
request executed at runtime is served by the Nth recorded interaction, and
each one is validated as the cursor advances. Request equality is computed
on canonicalized method, URL, headers, and JSON body (object keys sorted).
By default, requests match on canonicalized method, URL, headers, and JSON
body (object keys sorted). Two dispatch strategies are available:
This is deliberately strict — content-based dispatch was removed because
it silently returns the first recorded response for repeated identical
requests, masking state changes that retry/polling/cache-hit tests need to
observe. If you reorder requests in a test, re-record the cassette.
- **`match`** (default) — find the first recorded interaction whose request
matches the incoming request. Same request twice returns the same response.
- **`sequential`** — return interactions in the order they were recorded,
validating each one matches as the cursor advances. Use for ordered flows
where the same URL is hit multiple times with meaningful state changes
(pagination, retries, polling).
```ts
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
```
Supply your own matcher via `match: (incoming, recorded) => boolean` for
custom equivalence (e.g. ignoring a timestamp field in the body).
@@ -92,6 +101,7 @@ secrets escape. Redaction is configured by composing a `Redactor`:
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
HttpRecorder.cassetteLayer("anthropic/messages", {
mode: process.env.RECORD === "true" ? "record" : "replay",
redactor: Redactor.defaults({
requestHeaders: { allow: ["content-type", "anthropic-version"] },
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
@@ -147,6 +157,7 @@ const program = Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "ws/subscribe",
mode: process.env.RECORD === "true" ? "record" : "replay",
cassette,
live: liveExecutor,
})
@@ -156,9 +167,9 @@ const program = Effect.gen(function* () {
## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
returns the recorded interactions for a name; the file format is hidden
behind the seam. Useful for CI checks:
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
`scan` (re-running the secret detector over an existing cassette). Useful
for CI checks:
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
@@ -166,30 +177,22 @@ import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
cassettes
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
)
return issues.filter((i) => i.findings.length > 0)
return findings.filter((r) => r.findings.length > 0)
})
```
`cassetteLayer` is the batteries-included entry point — it provides
`Cassette.fileSystem({ directory })` automatically. If you want to provide
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
`Cassette.memory` yourself.
## Options reference
```ts
type RecordReplayOptions = {
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
mode?: "record" | "replay" | "passthrough" // default: "replay"
directory?: string // default: <cwd>/test/fixtures/recordings
metadata?: Record<string, unknown> // merged into cassette.metadata
redactor?: Redactor // default: Redactor.defaults()
dispatch?: "match" | "sequential" // default: "match"
match?: (incoming, recorded) => boolean // custom matcher
}
```
@@ -206,4 +209,4 @@ type RecordReplayOptions = {
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.46",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+82 -119
View File
@@ -1,76 +1,62 @@
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import * as fs from "node:fs"
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
import * as path from "node:path"
import { secretFindings, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
cassetteName: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
export interface Entry {
readonly name: string
readonly path: string
}
export interface Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
const buildCassette = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const directoriesEnsured = new Set<string>()
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
const pathFor = (name: string) => cassettePath(name, directory)
const ensureDirectory = (name: string) =>
Effect.gen(function* () {
const dir = path.dirname(cassettePath(name))
if (directoriesEnsured.has(dir)) return
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
directoriesEnsured.add(dir)
})
const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
const dir = path.dirname(pathFor(name))
if (directoriesEnsured.has(dir)) return
yield* fileSystem.makeDirectory(dir, { recursive: true })
directoriesEnsured.add(dir)
})
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry)
return fs.stat(full).pipe(
const full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
@@ -78,73 +64,50 @@ export const fileSystem = (
return nested.flat()
})
return Service.of({
read: (name) =>
fs.readFileString(cassettePath(name)).pipe(
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry)
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) =>
path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
)
.toSorted((a, b) => a.localeCompare(b)),
),
),
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* ensureDirectory(name)
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
recorded.set(name, entry)
const cassette = cassetteFor(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
Effect.sync(() => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const findings = accumulatedFindings.get(name)
if (findings) findings.push(...secretFindings(interaction))
else accumulatedFindings.set(name, [...secretFindings(interaction)])
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
return { findings: accumulatedFindings.get(name) ?? [] }
}),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})
+12 -11
View File
@@ -11,19 +11,20 @@ import {
UrlParams,
} from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { redactUrl } from "./redaction"
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redactor?: Redactor
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher
}
@@ -68,8 +69,8 @@ export const recordingLayer = (
const cassetteService = yield* CassetteService.Service
const redactor = options.redactor ?? defaults()
const match = options.match ?? defaultMatcher
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
@@ -113,16 +114,16 @@ export const recordingLayer = (
return Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const interactions = yield* replay.load.pipe(
Effect.mapError(() =>
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
),
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
)
const result = selectSequential(interactions, incoming, match, yield* replay.cursor)
const result = sequential
? selectSequential(interactions, incoming, match, yield* replay.cursor)
: selectMatch(interactions, incoming, match)
if (!result.interaction)
return yield* Effect.fail(
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
)
yield* replay.advance
if (sequential) yield* replay.advance
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
@@ -134,7 +135,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+2 -2
View File
@@ -7,9 +7,9 @@ export type {
WebSocketFrame,
WebSocketInteraction,
} from "./schema"
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { hasCassetteSync } from "./storage"
export { defaultMatcher, type RequestMatcher } from "./matching"
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
export { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export {
+18
View File
@@ -92,6 +92,24 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot
return lines
}
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const selectMatch = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
}
export const selectSequential = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
+23 -35
View File
@@ -1,49 +1,37 @@
import { Effect, Ref, Schema, Scope } from "effect"
import { Effect, PlatformError, Ref, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
import { SecretFindingSchema } from "./redaction"
import type { CassetteMetadata, Interaction } from "./schema"
import type { SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
export class UnsafeCassetteError extends Error {
readonly _tag = "UnsafeCassetteError"
constructor(
readonly cassetteName: string,
readonly findings: ReadonlyArray<SecretFinding>,
) {
super(
`Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`,
)
}
}
export type ResolvedMode = "record" | "replay" | "passthrough"
const isCI = () => {
const value = process.env.CI
return value !== undefined && value !== "" && value !== "false" && value !== "0"
}
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
Effect.gen(function* () {
if (isCI()) return "replay"
return (yield* cassette.exists(name)) ? "replay" : "record"
})
export const appendOrFail = (
cassette: CassetteService.Interface,
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
): Effect.Effect<void, UnsafeCassetteError> =>
cassette
.append(name, interaction, metadata)
.pipe(
Effect.flatMap(({ findings }) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
),
)
): Effect.Effect<Cassette, UnsafeCassetteError> =>
cassette.append(name, interaction, metadata).pipe(
Effect.orDie,
Effect.flatMap(({ cassette: result, findings }) =>
findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)),
),
)
export interface ReplayState<T> {
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
readonly cursor: Effect.Effect<number>
readonly advance: Effect.Effect<void>
}
@@ -51,7 +39,7 @@ export interface ReplayState<T> {
export const makeReplayState = <T>(
cassette: CassetteService.Interface,
name: string,
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
project: (cassette: Cassette) => ReadonlyArray<T>,
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
Effect.gen(function* () {
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
+8 -7
View File
@@ -1,3 +1,5 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
@@ -95,13 +97,10 @@ export const redactHeaders = (
)
}
import { Schema } from "effect"
export const SecretFindingSchema = Schema.Struct({
path: Schema.String,
reason: Schema.String,
})
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
export type SecretFinding = {
readonly path: string
readonly reason: string
}
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
@@ -113,3 +112,5 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+2 -3
View File
@@ -52,10 +52,9 @@ export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
interactions.filter(isWebSocketInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
+28
View File
@@ -0,0 +1,28 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+3 -5
View File
@@ -2,8 +2,7 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import type { RecordReplayMode } from "./effect"
import { appendOrFail, makeReplayState } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
@@ -24,7 +23,7 @@ export interface WebSocketExecutor<E> {
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: RecordReplayMode
readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
@@ -72,8 +71,7 @@ export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
const mode = options.mode ?? "replay"
const redactor = options.redactor ?? defaults()
const openSnapshot = (request: WebSocketRequest) => {
const redacted = redactor.request({
+99 -104
View File
@@ -7,15 +7,7 @@ import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/effect"
import type { Interaction } from "../src/schema"
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
)
import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
const post = (url: string, body: object) =>
Effect.gen(function* () {
@@ -42,7 +34,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
@@ -117,7 +109,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.secretFindings({
HttpRecorder.cassetteSecretFindings({
version: 1,
interactions: [
{
@@ -145,7 +137,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.secretFindings({
HttpRecorder.cassetteSecretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
@@ -153,42 +145,60 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
test("formats websocket cassettes with shared metadata", () => {
const cassette = cassetteFor(
"websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}).pipe(
Effect.provide(
HttpRecorder.Cassette.memory({
"websocket/replay": [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
}),
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
),
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
)
})
@@ -218,22 +228,34 @@ describe("http-recorder", () => {
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject([
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
])
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
})
}),
)
})
test("replay returns recorded responses in order for identical requests", async () => {
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith(
"record-replay/retry",
{},
{ dispatch: "sequential" },
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
@@ -241,8 +263,21 @@ describe("http-recorder", () => {
)
})
test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
await run(
test("default matcher returns the first match for identical requests", async () => {
await runWith(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 })
@@ -252,8 +287,10 @@ describe("http-recorder", () => {
)
})
test("replay validates each recorded request in order", async () => {
await run(
test("sequential dispatch still validates each recorded request", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
@@ -264,56 +301,14 @@ describe("http-recorder", () => {
)
})
test("auto mode replays when the cassette exists", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
await seedCassetteDirectory(directory, "auto-replay", [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/echo",
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: 1 }),
},
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
},
])
const result = await runWith(
"auto-replay",
{ directory, mode: "auto" },
post("https://example.test/echo", { step: 1 }),
)
expect(result).toBe('{"reply":"hi"}')
})
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
const previous = process.env.CI
process.env.CI = "true"
try {
const exit = await Effect.runPromise(
Effect.exit(
post("https://example.test/echo", { step: 1 }).pipe(
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
} finally {
if (previous === undefined) delete process.env.CI
else process.env.CI = previous
}
})
test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
test("mismatch diagnostics show closest redacted request differences", async () => {
await run(
Effect.gen(function* () {
const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
)
const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:")
+4 -8
View File
@@ -8,10 +8,6 @@
- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`.
- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void.
## Conventions
Per-type constructors live on the type's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `LLM.generateObject`. Two ways to construct the same thing is one too many.
## Tests
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
@@ -170,12 +166,12 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
Tool loops are represented in common messages and events:
```ts
const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } })
const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })
const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } })
const followUp = LLM.request({
model,
messages: [Message.user("Weather?"), Message.assistant([call]), result],
messages: [LLM.user("Weather?"), LLM.assistant([call]), result],
})
```
@@ -293,6 +289,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re
**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text.
**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
-129
View File
@@ -1,129 +0,0 @@
# @opencode-ai/llm
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/llm"
import { OpenAI } from "@opencode-ai/llm/providers"
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model,
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
### Auto placement
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
### Opting out
```ts
LLM.request({
model,
system,
prompt: "one-off question",
cache: "none",
})
```
### Granular policy
```ts
cache: {
tools?: boolean,
system?: boolean,
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
}
```
### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
```ts
LLM.request({
model,
system: [
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
],
...
})
```
### Provider behavior table
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
## Providers
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
```ts
import { Anthropic } from "@opencode-ai/llm/providers"
const model = Anthropic.model("claude-sonnet-4-6", {
apiKey: process.env.ANTHROPIC_API_KEY,
})
```
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
## Provider options & HTTP overlays
Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Model-level defaults are overridden by request-level values for each axis.
## Routes
Adding a new model or deployment is usually 515 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
- `AGENTS.md` — architecture, route construction, contributor guide
- `example/tutorial.ts` — runnable end-to-end walkthrough
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
+1 -1
View File
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
stream: {
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
onHalt: () => [{ type: "request-finish", reason: "stop" }],
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.46",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
-111
View File
@@ -1,111 +0,0 @@
// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
//
// Manual `cache: CacheHint` placements on individual parts are preserved —
// this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: "latest-user-message",
}
const NONE: CachePolicyObject = {}
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + system + latest user msg.
// - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
if (policy === undefined || policy === "auto") return AUTO
if (policy === "none") return NONE
return policy
}
// 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", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache) return tools
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
}
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
if (system.length === 0) return system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
}
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
messages.findLastIndex((m) => m.role === role)
// Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if ("cache" in existing && existing.cache) return messages
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
// conversations call this on every request, so avoid `.map()` here — its
// closure dispatch and identity copies show up in profiling.
const result = messages.slice()
result[index] = next
return result
}
const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
return next
}
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
}
+28 -4
View File
@@ -44,8 +44,32 @@ export type RequestInput = Omit<
export const limits = modelLimits
export const text = Message.text
export const system = SystemPart.make
export const message = Message.make
export const user = Message.user
export const assistant = Message.assistant
export const model = modelRef
export const toolDefinition = ToolDefinition.make
export const toolCall = ToolCallPart.make
export const toolResult = ToolResultPart.make
export const toolMessage = Message.tool
export const toolChoiceName = ToolChoice.named
export const toolChoice = ToolChoice.make
export const generation = GenerationOptions.make
export const generate = LLMClient.generate
export const stream = LLMClient.stream
@@ -71,10 +95,10 @@ export const request = (input: RequestInput) => {
return new LLMRequest({
...rest,
system: SystemPart.content(requestSystem),
messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
tools: tools?.map(ToolDefinition.make) ?? [],
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
messages: [...(messages?.map(message) ?? []), ...(prompt === undefined ? [] : [user(prompt)])],
tools: tools?.map(toolDefinition) ?? [],
toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : generation(requestGeneration),
providerOptions: requestProviderOptions,
http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp),
})
+64 -163
View File
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type CacheHint,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ProviderMetadata,
type ToolCallPart,
@@ -16,8 +16,6 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
@@ -27,10 +25,7 @@ export const PATH = "/messages"
// =============================================================================
// Request Body Schema
// =============================================================================
const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") })
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -191,7 +186,6 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly tools: ToolStream.State<number>
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
@@ -199,24 +193,8 @@ const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
// Anthropic accepts at most 4 explicit cache_control breakpoints per request,
// across `tools`, `system`, and `messages`. Beyond the cap the API returns a
// 400 — so the lowering layer counts emitted markers and silently drops any
// that exceed it.
const ANTHROPIC_BREAKPOINT_CAP = 4
const EPHEMERAL_5M = { type: "ephemeral" as const }
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const cacheControl = (cache: CacheHint | undefined) =>
cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
@@ -226,11 +204,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -272,10 +249,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
})
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) {
const messages: AnthropicMessage[] = []
for (const message of request.messages) {
@@ -284,7 +258,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text"]))
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
}
messages.push({ role: "user", content })
continue
@@ -294,7 +268,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
continue
}
if (part.type === "reasoning") {
@@ -330,7 +304,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
tool_use_id: part.id,
content: ProviderShared.toolResultText(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
})
}
messages.push({ role: "user", content })
@@ -357,33 +330,18 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined
: request.tools.map((tool) => lowerTool(breakpoints, tool))
const system =
request.system.length === 0
? undefined
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
return {
model: request.model.id,
system,
messages,
tools,
system:
request.system.length === 0
? undefined
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(part.cache),
})),
messages: yield* lowerMessages(request),
tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool),
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,
@@ -406,56 +364,34 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
return "unknown"
}
// Anthropic reports the non-overlapping breakdown natively — its
// `input_tokens` is the *non-cached* count per the Messages API docs, with
// cache reads and writes as separate fields. We sum them to derive the
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are *not* broken out by Anthropic — they're billed as
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
// `outputTokens` carries the combined total.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
return new Usage({
inputTokens,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined,
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined),
native: usage,
})
}
// Anthropic emits usage on `message_start` and again on `message_delta` — the
// final delta carries the authoritative totals. Right-biased merge: each
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
// recomputed from the merged breakdown so the inclusive total stays
// consistent with `nonCached + cacheRead + cacheWrite`.
// field prefers `right` when defined, falls back to `left`. `totalTokens` is
// recomputed from the merged input/output to stay consistent.
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
if (!left) return right
if (!right) return left
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
const inputTokens = right.inputTokens ?? left.inputTokens
const outputTokens = right.outputTokens ?? left.outputTokens
return new Usage({
inputTokens,
outputTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens,
cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic: {
...(left.providerMetadata?.["anthropic"] ?? {}),
...(right.providerMetadata?.["anthropic"] ?? {}),
},
},
native: { ...left.native, ...right.native },
})
}
@@ -479,13 +415,14 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
? String((block.content as Record<string, unknown>).type)
: ""
const isError = errorPayload.endsWith("_tool_result_error")
return LLMEvent.toolResult({
return {
type: "tool-result",
id: block.tool_use_id ?? "",
name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }),
})
}
}
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -502,45 +439,38 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
if (!block) return [state, NO_EVENTS]
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, event.index, {
id: block.id ?? String(event.index),
name: block.name ?? "",
providerExecuted: block.type === "server_tool_use",
}),
},
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
NO_EVENTS,
]
}
if (block.type === "text" && block.text) {
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
events,
]
return [state, [{ type: "text-delta", text: block.text }]]
}
if (block.type === "thinking" && block.thinking) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
},
events,
state,
[
{
type: "reasoning-delta",
text: block.thinking,
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
},
],
]
}
const result = serverToolResultEvent(block)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
return [state, result ? [result] : NO_EVENTS]
}
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
@@ -550,37 +480,17 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
events,
] satisfies StepResult
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
}
if (delta?.type === "thinking_delta" && delta.thinking) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking),
},
events,
] satisfies StepResult
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
}
if (delta?.type === "signature_delta" && delta.signature) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningEnd(
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ signature: delta.signature }),
),
},
events,
state,
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
] satisfies StepResult
}
@@ -594,10 +504,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
"Anthropic Messages tool argument delta is missing its tool call",
)
if (ToolStream.isError(result)) return yield* result
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult
}
return [state, NO_EVENTS] satisfies StepResult
@@ -609,35 +516,29 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
) {
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
)
events.push(...resultEvents)
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event.delta?.stop_reason),
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
: undefined,
})
return [{ ...state, lifecycle, usage }, events]
return [
{ ...state, usage },
[
{
type: "request-finish",
reason: mapFinishReason(event.delta?.stop_reason),
usage,
...(event.delta?.stop_sequence
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
: {}),
},
],
]
}
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
]
const step = (state: ParserState, event: AnthropicEvent) => {
@@ -666,7 +567,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
initial: () => ({ tools: ToolStream.empty<number>() }),
step,
},
})
+31 -134
View File
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type CacheHint,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ToolCallPart,
type ToolDefinition,
@@ -17,7 +17,6 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "bedrock-converse"
@@ -109,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
const BedrockToolSpec = Schema.Struct({
const BedrockTool = Schema.Struct({
toolSpec: Schema.Struct({
name: Schema.String,
description: Schema.String,
@@ -118,9 +117,6 @@ const BedrockToolSpec = Schema.Struct({
}),
}),
})
type BedrockToolSpec = Schema.Schema.Type<typeof BedrockToolSpec>
const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock])
type BedrockTool = Schema.Schema.Type<typeof BedrockTool>
const BedrockToolChoice = Schema.Union([
@@ -218,7 +214,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// =============================================================================
// Request Lowering
// =============================================================================
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
const lowerTool = (tool: ToolDefinition): BedrockTool => ({
toolSpec: {
name: tool.name,
description: tool.description,
@@ -226,22 +222,11 @@ const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
},
})
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
const result: BedrockTool[] = []
for (const tool of tools) {
result.push(lowerToolSpec(tool))
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint)
}
return result
}
const textWithCache = (
breakpoints: BedrockCache.Breakpoints,
text: string,
cache: CacheHint | undefined,
): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => {
const cachePoint = BedrockCache.block(breakpoints, cache)
const cachePoint = BedrockCache.block(cache)
return cachePoint ? [{ text }, cachePoint] : [{ text }]
}
@@ -272,10 +257,7 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
},
})
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
) {
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) {
const messages: BedrockMessage[] = []
for (const message of request.messages) {
@@ -285,7 +267,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
if (!ProviderShared.supportsContent(part, ["text", "media"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
if (part.type === "text") {
content.push(...textWithCache(breakpoints, part.text, part.cache))
content.push(...textWithCache(part.text, part.cache))
continue
}
if (part.type === "media") {
@@ -307,7 +289,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
"tool-call",
])
if (part.type === "text") {
content.push(...textWithCache(breakpoints, part.text, part.cache))
content.push(...textWithCache(part.text, part.cache))
continue
}
if (part.type === "reasoning") {
@@ -327,13 +309,11 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
const content: BedrockUserBlock[] = []
const content: BedrockToolResultBlock[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
messages.push({ role: "user", content })
}
@@ -343,32 +323,16 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
const lowerSystem = (system: ReadonlyArray<LLMRequest["system"][number]>): BedrockSystemBlock[] =>
system.flatMap((part) => textWithCache(part.text, part.cache))
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
)
}
return {
modelId: request.model.id,
messages,
system,
messages: yield* lowerMessages(request),
system: request.system.length === 0 ? undefined : lowerSystem(request.system),
inferenceConfig:
generation?.maxTokens === undefined &&
generation?.temperature === undefined &&
@@ -381,7 +345,10 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
topP: generation?.topP,
stopSequences: generation?.stop,
},
toolConfig,
toolConfig:
request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: request.tools.map(lowerTool), toolChoice }
: undefined,
}
})
@@ -396,22 +363,15 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
return new Usage({
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
native: usage,
})
}
@@ -421,64 +381,32 @@ interface ParserState {
// `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.
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
}
const step = (state: ParserState, event: BedrockEvent) =>
Effect.gen(function* () {
if (event.contentBlockStart?.start?.toolUse) {
const index = event.contentBlockStart.contentBlockIndex
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, index, {
id: event.contentBlockStart.start.toolUse.toolUseId,
name: event.contentBlockStart.start.toolUse.name,
}),
},
[
...events,
LLMEvent.toolInputStart({
id: event.contentBlockStart.start.toolUse.toolUseId,
name: event.contentBlockStart.start.toolUse.name,
}),
],
[],
] as const
}
if (event.contentBlockDelta?.delta?.text) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.textDelta(
state.lifecycle,
events,
`text-${event.contentBlockDelta.contentBlockIndex}`,
event.contentBlockDelta.delta.text,
),
},
events,
] as const
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
}
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${event.contentBlockDelta.contentBlockIndex}`,
event.contentBlockDelta.delta.reasoningContent.text,
),
},
events,
state,
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
] as const
}
@@ -492,33 +420,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
"Bedrock Converse tool delta is missing its tool call",
)
if (ToolStream.isError(result)) return yield* result
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
return [{ ...state, lifecycle, tools: result.tools }, events] as const
return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const
}
if (event.contentBlockStop) {
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${event.contentBlockStop.contentBlockIndex}`),
events,
`reasoning-${event.contentBlockStop.contentBlockIndex}`,
)
events.push(...resultEvents)
return [
{
...state,
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
lifecycle,
tools: result.tools,
},
events,
] as const
return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const
}
if (event.messageStop) {
@@ -542,13 +449,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
event.modelStreamErrorException?.message ??
event.serviceUnavailableException?.message ??
"Bedrock Converse stream error"
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
}
if (event.validationException || event.throttlingException) {
const message =
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
return [
state,
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
] as const
}
return [state, []] as const
@@ -558,15 +468,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.pendingFinish
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason:
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
usage: state.pendingFinish.usage,
})
return events
})()
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
: []
// =============================================================================
@@ -584,12 +486,7 @@ export const protocol = Protocol.make({
},
stream: {
event: BedrockEvent,
initial: () => ({
tools: ToolStream.empty<number>(),
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ tools: ToolStream.empty<number>(), pendingFinish: undefined }),
step,
onHalt,
},
+11 -36
View File
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type MediaPart,
type TextPart,
@@ -16,7 +16,6 @@ import {
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
import { Lifecycle } from "./utils/lifecycle"
const ADAPTER = "gemini"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
@@ -135,9 +134,10 @@ interface ParserState {
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
const mediaData = ProviderShared.mediaBytes
// =============================================================================
@@ -281,28 +281,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// =============================================================================
// Stream Parsing
// =============================================================================
// Gemini reports `promptTokenCount` (inclusive total) with a
// `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) => {
if (!usage) return undefined
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 =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
return new Usage({
inputTokens: usage.promptTokenCount,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
outputTokens: usage.candidatesTokenCount,
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
providerMetadata: { google: usage },
cacheReadInputTokens: usage.cachedContentTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
native: usage,
})
}
@@ -324,14 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.finishReason || state.usage
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
usage: state.usage,
})
return events
})()
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
: []
const step = (state: ParserState, event: GeminiEvent) => {
@@ -348,22 +328,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
for (const part of candidate.content.parts) {
if ("text" in part && part.text.length > 0) {
lifecycle = part.thought
? Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text)
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
hasToolCalls = true
}
}
@@ -372,7 +348,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
{
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
@@ -396,7 +371,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }),
step,
onHalt: finish,
},
+11 -27
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type TextPart,
type ToolCallPart,
@@ -16,7 +16,6 @@ import {
} from "../schema"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat"
@@ -148,7 +147,6 @@ interface ParserState {
readonly toolCallEvents: ReadonlyArray<LLMEvent>
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
@@ -292,24 +290,15 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
return "unknown"
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// a `reasoning_tokens` subset. We pass the inclusive totals through and
// derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: reasoning,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
native: usage,
})
}
@@ -323,9 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
let lifecycle = state.lifecycle
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
@@ -337,8 +324,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
)
if (ToolStream.isError(result)) return yield* result
tools = result.tools
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(...result.events)
if (result.event) events.push(result.event)
}
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
@@ -354,20 +340,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
lifecycle,
},
events,
] as const
})
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...state.toolCallEvents)
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
return [
...state.toolCallEvents,
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
]
}
// =============================================================================
@@ -387,7 +371,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [] }),
step,
onHalt: finishEvents,
},
+49 -67
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ProviderMetadata,
type TextPart,
@@ -17,7 +17,6 @@ import {
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-responses"
@@ -166,7 +165,6 @@ type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
@@ -278,23 +276,15 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
// =============================================================================
// Stream Parsing
// =============================================================================
// OpenAI Responses reports `input_tokens` (inclusive total) with a
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
// `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: reasoning,
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
cacheReadInputTokens: usage.input_tokens_details?.cached_tokens,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
providerMetadata: { openai: usage },
native: usage,
})
}
@@ -358,20 +348,22 @@ const hostedToolEvents = (
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
LLMEvent.toolCall({
{
type: "tool-call",
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
},
{
type: "tool-result",
id: item.id,
name: tool.name,
result: hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
},
]
}
@@ -387,32 +379,33 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
events,
state,
[
{
type: "text-delta",
id: event.item_id,
text: event.delta,
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
},
],
]
}
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const providerMetadata = openaiMetadata({ itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
hasFunctionCall: state.hasFunctionCall,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata,
providerMetadata: openaiMetadata({ itemId: item.id }),
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })],
NO_EVENTS,
]
}
@@ -429,10 +422,10 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallAr
"OpenAI Responses tool argument delta is missing its tool call",
)
if (ToolStream.isError(result)) return yield* result
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
return [
{ hasFunctionCall: state.hasFunctionCall, tools: result.tools },
result.event ? [result.event] : NO_EVENTS,
] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* (
@@ -451,55 +444,44 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
item.arguments === undefined
? yield* ToolStream.finish(ADAPTER, tools, item.id)
: yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
{
...state,
lifecycle,
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
tools: result.tools,
},
events,
{ hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools },
result.event ? [result.event] : NO_EVENTS,
] satisfies StepResult
}
if (isHostedToolItem(item)) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(...hostedToolEvents(item))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: mapFinishReason(event, state.hasFunctionCall),
usage: mapUsage(event.response?.usage),
providerMetadata:
event.response?.id || event.response?.service_tier
? openaiMetadata({
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[
{
type: "request-finish",
reason: mapFinishReason(event, state.hasFunctionCall),
usage: mapUsage(event.response?.usage),
...(event.response?.id || event.response?.service_tier
? {
providerMetadata: openaiMetadata({
responseId: event.response.id,
serviceTier: event.response.service_tier,
}),
}
: {}),
},
],
]
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
@@ -530,7 +512,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIResponsesEvent),
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>() }),
step,
terminal: (event) => TERMINAL_TYPES.has(event.type),
},
-36
View File
@@ -42,13 +42,6 @@ export interface ToolAccumulator {
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
* when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`.
*
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so
* Anthropic-style providers (which don't surface a total) still get a
* sensible aggregate on the input + output axes.
*/
export const totalTokens = (
inputTokens: number | undefined,
@@ -60,35 +53,6 @@ export const totalTokens = (
return (inputTokens ?? 0) + (outputTokens ?? 0)
}
/**
* Subtract `subtrahend` from `total`, clamping to zero if the provider
* reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
* Used by protocol mappers when deriving a non-overlapping breakdown field
* from a provider's inclusive total — `nonCachedInputTokens` from
* `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
*
* If `total` is `undefined`, returns `undefined` (we don't fabricate
* counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
* provider-native breakdown stays available on `Usage.native` for debugging.
*/
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
if (total === undefined) return undefined
if (subtrahend === undefined) return total
return Math.max(0, total - subtrahend)
}
/**
* Sum a list of optional token counts, returning `undefined` only when
* every value is `undefined` (so we don't fabricate a `0`). Used by
* protocol mappers to derive the inclusive `inputTokens` total from a
* provider that natively reports a non-overlapping breakdown
* (e.g. Anthropic, whose `input_tokens` is already non-cached only).
*/
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
if (values.every((value) => value === undefined)) return undefined
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
}
export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({
module: "ProviderShared",
@@ -1,37 +1,20 @@
import { Schema } from "effect"
import type { CacheHint } from "../../schema"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
// after the content the caller wants treated as a cacheable prefix. Bedrock
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
// after the content the caller wants treated as a cacheable prefix.
export const CachePointBlock = Schema.Struct({
cachePoint: Schema.Struct({
type: Schema.tag("default"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
}),
cachePoint: Schema.Struct({ type: Schema.tag("default") }),
})
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
// API. Callers pass a shared counter through every `block()` call site so the
// budget is respected across `system`, `messages`, and `tools`.
export const BEDROCK_BREAKPOINT_CAP = 4
// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map
// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape.
const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } }
export type { Breakpoints } from "./cache"
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => {
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
return DEFAULT
}
export * as BedrockCache from "./bedrock-cache"
-16
View File
@@ -1,16 +0,0 @@
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
// TTL buckets, so the counter and TTL mapping live here.
export interface Breakpoints {
remaining: number
dropped: number
}
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
// provider default 5m). Anthropic & Bedrock both treat anything shorter than
// an hour as 5m.
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
@@ -1,88 +0,0 @@
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
readonly text: ReadonlySet<string>
readonly reasoning: ReadonlySet<string>
}
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
export const stepStart = (state: State, events: LLMEvent[]): State => {
if (state.stepStarted) return state
events.push(LLMEvent.stepStart({ index: 0 }))
return { ...state, stepStarted: true }
}
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
events.push(LLMEvent.textDelta({ id, text }))
return stepped
}
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const stepped = stepStart(state, events)
if (stepped.reasoning.has(id)) {
events.push(LLMEvent.reasoningDelta({ id, text }))
return stepped
}
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
export const reasoningEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
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): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
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 => {
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
return { ...state, text: new Set(), reasoning: new Set() }
}
export const finish = (
state: State,
events: LLMEvent[],
input: {
readonly reason: FinishReason
readonly usage?: Usage
readonly providerMetadata?: ProviderMetadata
},
): State => {
const stepped = closeOpenBlocks(stepStart(state, events), events)
events.push(
LLMEvent.stepFinish({
index: 0,
reason: input.reason,
usage: input.usage,
providerMetadata: input.providerMetadata,
}),
LLMEvent.requestFinish(input),
)
return { ...stepped, stepStarted: false }
}
export * as Lifecycle from "./lifecycle"
+37 -59
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -27,13 +27,13 @@ export type State<K extends StreamKey> = Partial<Record<K, PendingTool>>
/**
* Result of adding argument text to one pending tool call. It returns both the
* next `tools` state and the updated `tool` because parsers often need the
* current id/name immediately. `events` contains lifecycle and delta events
* produced by the append; metadata-only deltas update identity without output.
* current id/name immediately. `event` is present only when new text arrived;
* metadata-only deltas update identity without emitting `tool-input-delta`.
*/
export interface AppendOutcome<K extends StreamKey> {
readonly tools: State<K>
readonly tool: PendingTool
readonly events: ReadonlyArray<LLMEvent>
readonly event?: ToolInputDelta
}
/** Create empty accumulator state for one provider stream. */
@@ -49,31 +49,34 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
return next
}
const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
name: tool.name,
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
})
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
type: "tool-input-delta",
id: tool.id,
name: tool.name,
text,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
tool.providerExecuted
? {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
providerExecuted: true,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
}
: {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
},
),
)
@@ -83,16 +86,11 @@ const appendTool = <K extends StreamKey>(
key: K,
tool: PendingTool,
text: string,
): AppendOutcome<K> => {
const events: LLMEvent[] = []
if (!tools[key]) events.push(inputStart(tool))
if (text.length > 0) events.push(inputDelta(tool, text))
return {
tools: withTool(tools, key, tool),
tool,
events,
}
}
): AppendOutcome<K> => ({
tools: withTool(tools, key, tool),
tool,
event: text.length === 0 ? undefined : inputDelta(tool, text),
})
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError
@@ -133,8 +131,7 @@ export const appendOrStart = <K extends StreamKey>(
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
}
if (current && delta.text.length === 0 && current.id === id && current.name === name)
return { tools, tool: current, events: [] }
if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current }
return appendTool(tools, key, tool, delta.text)
}
@@ -152,7 +149,7 @@ export const appendExisting = <K extends StreamKey>(
): AppendOutcome<K> | LLMError => {
const current = tools[key]
if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] }
if (text.length === 0) return { tools, tool: current }
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
}
@@ -165,13 +162,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
Effect.gen(function* () {
const tool = tools[key]
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool),
],
}
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) }
})
/**
@@ -183,13 +174,7 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
Effect.gen(function* () {
const tool = tools[key]
if (!tool) return { tools }
return {
tools: withoutTool(tools, key),
events: [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
yield* toolCall(route, tool, input),
],
}
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) }
})
/**
@@ -204,14 +189,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
)
return {
tools: empty<K>(),
events: yield* Effect.forEach(pending, (tool) =>
toolCall(route, tool).pipe(
Effect.map((call) => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
call,
]),
),
).pipe(Effect.map((events) => events.flat())),
events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)),
}
})
+1 -2
View File
@@ -8,7 +8,6 @@ import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Service as WebSocketExecutorService } from "./transport/websocket"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import * as ToolRuntime from "../tool-runtime"
import type { Tools } from "../tool"
@@ -401,7 +400,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const resolved = resolveRequestOptions(request)
const route = registeredRoute(resolved.model.route)
if (!route) return yield* noRoute(resolved.model)
+29 -147
View File
@@ -1,155 +1,73 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
import { ModelRef } from "./options"
import { ToolResultValue } from "./messages"
/**
* Token usage reported by an LLM provider.
*
* **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a
* reader from any of those ecosystems sees the number they expect):
*
* - `inputTokens` — total prompt tokens, *including* cached reads/writes.
* - `outputTokens` — total output tokens, *including* reasoning.
* - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`.
*
* **Non-overlapping breakdown** (every field is independently meaningful;
* consumers never have to subtract):
*
* - `nonCachedInputTokens` — the "fresh" portion of the prompt.
* - `cacheReadInputTokens` — input tokens served from cache.
* - `cacheWriteInputTokens` — input tokens written to cache.
* - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning.
*
* **Invariant**: `nonCachedInputTokens + cacheReadInputTokens +
* cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`.
* Each protocol mapper computes whichever side it doesn't get natively,
* with `Math.max(0, …)` clamping for defense against provider bugs. Because
* every breakdown field is stored independently, downstream consumers can
* read whatever they need (cost-by-category, context-pressure, AI-SDK-style
* inclusive total) without ever subtracting — eliminating the underflow
* class of bug where a clamped difference would silently store the wrong
* value.
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
*
* `providerMetadata` always carries the provider's raw usage payload —
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
* — for fields we don't normalize and for billing-level audit trails.
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
cacheReadInputTokens: Schema.optional(Schema.Number),
cacheWriteInputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
*/
get visibleOutputTokens() {
return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
}
}
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"),
id: ResponseID,
type: Schema.Literal("request-start"),
id: Schema.String,
model: ModelRef,
}).annotate({ identifier: "LLM.Event.RequestStart" })
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
type: Schema.Literal("step-start"),
index: Schema.Number,
}).annotate({ identifier: "LLM.Event.StepStart" })
export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({
type: Schema.tag("text-start"),
id: ContentBlockID,
type: Schema.Literal("text-start"),
id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart>
export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
type: Schema.Literal("text-delta"),
id: Schema.optional(Schema.String),
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
type: Schema.Literal("text-end"),
id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
type: Schema.Literal("reasoning-delta"),
id: Schema.optional(Schema.String),
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
type: Schema.Literal("tool-input-delta"),
id: Schema.String,
name: Schema.String,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
type: Schema.Literal("tool-call"),
id: Schema.String,
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -158,8 +76,8 @@ export const ToolCall = Schema.Struct({
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
type: Schema.Literal("tool-result"),
id: Schema.String,
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -168,8 +86,8 @@ export const ToolResult = Schema.Struct({
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
type: Schema.Literal("tool-error"),
id: Schema.String,
name: Schema.String,
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
@@ -177,7 +95,7 @@ export const ToolError = Schema.Struct({
export type ToolError = Schema.Schema.Type<typeof ToolError>
export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"),
type: Schema.Literal("step-finish"),
index: Schema.Number,
reason: FinishReason,
usage: Schema.optional(Usage),
@@ -186,7 +104,7 @@ export const StepFinish = Schema.Struct({
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const RequestFinish = Schema.Struct({
type: Schema.tag("request-finish"),
type: Schema.Literal("request-finish"),
reason: FinishReason,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -194,7 +112,7 @@ export const RequestFinish = Schema.Struct({
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
type: Schema.Literal("provider-error"),
message: Schema.String,
retryable: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -207,12 +125,8 @@ const llmEventTagged = Schema.Union([
TextStart,
TextDelta,
TextEnd,
ReasoningStart,
ReasoningDelta,
ReasoningEnd,
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
ToolCall,
ToolResult,
ToolError,
@@ -221,52 +135,20 @@ const llmEventTagged = Schema.Union([
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
const responseID = (value: ResponseID | string) => ResponseID.make(value)
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
/**
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
stepFinish: StepFinish.make,
requestFinish: RequestFinish.make,
providerError: ProviderErrorEvent.make,
is: {
requestStart: llmEventTagged.guards["request-start"],
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
textEnd: llmEventTagged.guards["text-end"],
reasoningStart: llmEventTagged.guards["reasoning-start"],
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
reasoningEnd: llmEventTagged.guards["reasoning-end"],
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
-9
View File
@@ -14,15 +14,6 @@ export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
+1 -6
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -79,7 +79,6 @@ export const ToolResultPart = Object.assign(
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolResult" }),
@@ -95,7 +94,6 @@ export const ToolResultPart = Object.assign(
name: input.name,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
cache: input.cache,
metadata: input.metadata,
providerMetadata: input.providerMetadata,
}),
@@ -153,7 +151,6 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
name: Schema.String,
description: Schema.String,
inputSchema: JsonSchema,
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -206,7 +203,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -224,7 +220,6 @@ export namespace LLMRequest {
providerOptions: request.providerOptions,
http: request.http,
responseFormat: request.responseFormat,
cache: request.cache,
metadata: request.metadata,
})
-28
View File
@@ -200,31 +200,3 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]),
ttlSeconds: Schema.optional(Schema.Number),
}) {}
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
export const CachePolicyObject = Schema.Struct({
tools: Schema.optional(Schema.Boolean),
system: Schema.optional(Schema.Boolean),
messages: Schema.optional(
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
})
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
+8 -16
View File
@@ -4,7 +4,7 @@ import {
type ContentPart,
type FinishReason,
type LLMError,
LLMEvent,
type LLMEvent,
LLMRequest,
Message,
type ProviderMetadata,
@@ -115,19 +115,11 @@ interface StepState {
const accumulate = (state: StepState, event: LLMEvent) => {
if (event.type === "text-delta") {
appendStreamingText(state, "text", event.text, undefined)
appendStreamingText(state, "text", event.text, event.providerMetadata)
return
}
if (event.type === "reasoning-delta") {
appendStreamingText(state, "reasoning", event.text, undefined)
return
}
if (event.type === "reasoning-end") {
appendStreamingText(state, "reasoning", "", event.providerMetadata)
return
}
if (event.type === "text-end") {
appendStreamingText(state, "text", "", event.providerMetadata)
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
return
}
if (event.type === "tool-call") {
@@ -154,8 +146,8 @@ const accumulate = (state: StepState, event: LLMEvent) => {
)
return
}
if (event.type === "step-finish" || event.type === "request-finish") {
state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason
if (event.type === "request-finish") {
state.finishReason = event.reason
}
}
@@ -227,10 +219,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
{ type: "tool-result", id: call.id, name: call.name, result },
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
: [{ type: "tool-result", id: call.id, name: call.name, result }]
const followUpRequest = (
request: LLMRequest,
+1 -3
View File
@@ -50,9 +50,7 @@ const request = LLM.request({
})
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "request-finish", reason: event.reason }
: { type: "text-delta", id: "text-0", text: event.text }
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
id: "fake",

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