Compare commits

..
Author SHA1 Message Date
Kit Langton b7d0582a1f fix(plugin): own effect plugin runtime 2026-08-24 09:17:45 -04:00
511 changed files with 4799 additions and 29973 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/plugin": patch
---
Export the Effect runtime used by Effect plugins and safely adapt its tool schemas across host module instances.
+1 -10
View File
@@ -135,16 +135,7 @@ jobs:
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
// GitHub only populates closingIssuesReferences when a PR targets the repository's
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
const body = pr.body || '';
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : body;
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
if (!hasLinkedIssue) {
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
+4 -47
View File
@@ -22,36 +22,6 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -119,12 +89,6 @@ jobs:
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
@@ -163,7 +127,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
@@ -174,38 +138,32 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -213,24 +171,23 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+24 -27
View File
@@ -346,6 +346,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
@@ -356,6 +357,7 @@
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
@@ -428,7 +430,6 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
@@ -594,8 +595,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"@opentui/core": ">=0.5.7",
"@opentui/solid": ">=0.5.7",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -665,7 +666,6 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -685,7 +685,6 @@
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -1091,9 +1090,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1205,6 +1204,8 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="],
@@ -1609,8 +1610,6 @@
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
"@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="],
@@ -2219,27 +2218,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.8", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.8", "@opentui/core-darwin-x64": "0.5.8", "@opentui/core-linux-arm64": "0.5.8", "@opentui/core-linux-arm64-musl": "0.5.8", "@opentui/core-linux-x64": "0.5.8", "@opentui/core-linux-x64-musl": "0.5.8", "@opentui/core-win32-arm64": "0.5.8", "@opentui/core-win32-x64": "0.5.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g=="],
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
"@opentui/keymap": ["@opentui/keymap@0.5.8", "", { "dependencies": { "@opentui/core": "0.5.8" }, "peerDependencies": { "@opentui/react": "0.5.8", "@opentui/solid": "0.5.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-KQHKRnLZroSZIbHmGmSeDPsXi7Yykdu9x9ACmDzBFkgPGx0EIuXHqRcmrqmUaX485MQEZuCD9t6FdDsOMGyIFg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
"@opentui/solid": ["@opentui/solid@0.5.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-L0NxuAU8XT+jlE5G90oA3kspqkof48b0hmzi5XLw+1gxnkxrkTb+YfKys+GzVK4UqhgwY9aW+TeDfrKbnfwCMw=="],
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5951,6 +5950,10 @@
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6129,8 +6132,6 @@
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-18050", "@opencode-ai/schema": "0.0.0-beta-18050" }, "peerDependencies": { "effect": "4.0.0-rc.111", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -6955,10 +6956,6 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
}
}
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.8",
"@opentui/keymap": "0.5.8",
"@opentui/solid": "0.5.8",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
+5 -5
View File
@@ -157,9 +157,9 @@ const PROVIDERS: ReadonlyArray<Provider> = [
id: "togetherai",
label: "TogetherAI",
tier: "compatible",
note: "Native Together AI text/tool recorded tests",
vars: [{ name: "TOGETHER_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_API_KEY)),
note: "Existing OpenAI-compatible text/tool recorded tests",
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
},
{
id: "minimax",
@@ -200,8 +200,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
{
id: "cerebras",
label: "Cerebras",
tier: "compatible",
note: "Native Cerebras text/tool/tool-loop recorded tests",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "CEREBRAS_API_KEY" }],
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
},
+1 -1
View File
@@ -36,7 +36,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
+2 -14
View File
@@ -1,8 +1,7 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor.js"
import { mergeHttpOptions, type AIError } from "./schema/index.js"
import { sanitizeSurrogates } from "./utils/sanitize.js"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
import type { AIError } from "./schema/index.js"
export type Execute = RequestExecutor.Interface["execute"]
@@ -27,18 +26,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) =>
request.model.route.generate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
generate: (request) => request.model.route.generate(request, executor.execute),
})
}),
)
+20 -120
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.String,
signature: Schema.optional(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -361,8 +361,6 @@ const AnthropicStreamBlock = Schema.Struct({
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
type: Schema.optional(Schema.String),
@@ -373,15 +371,13 @@ const AnthropicStreamDelta = Schema.Struct({
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
@@ -705,26 +701,6 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -744,12 +720,9 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
const previous = request.messages[index - 1]
const next = request.messages[index + 1]
// Vertex currently rejects/404s for a system message after local tool results,
// so fold it into the user tool-result turn across continuations and history.
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
return (
previous !== undefined &&
previous.role !== "system" &&
@@ -796,7 +769,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
@@ -834,30 +807,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
// thinking; only signature-less parts carrying redactedData
// round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
@@ -1113,7 +1071,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1140,10 +1098,7 @@ const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult =
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
}
const onContentBlockStart = (
state: ParserState,
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
@@ -1234,12 +1189,11 @@ const onContentBlockStart = (
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
state: ParserState,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
event: AnthropicEvent,
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
@@ -1248,7 +1202,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{
@@ -1261,7 +1214,6 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
{
...state,
@@ -1314,10 +1266,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
})
const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
@@ -1372,49 +1321,11 @@ const onError = (event: AnthropicEvent) =>
}),
)
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
ProviderShared.eventError(
ADAPTER,
"Invalid anthropic/anthropic-messages stream event",
ProviderShared.encodeJson(event),
),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (!SSE_EVENTS.has(event.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (
event.type !== "content_block_start" &&
event.content_block !== undefined &&
Option.isNone(decodeAnthropicStreamBlock(event.content_block))
)
return invalidStreamEvent(event)
if (
event.type !== "content_block_delta" &&
event.delta !== undefined &&
Option.isNone(decodeAnthropicStreamDelta(event.delta))
)
return invalidStreamEvent(event)
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
if (block.type === "tool_use" || block.type === "server_tool_use") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
@@ -1422,22 +1333,11 @@ const step = (state: ParserState, event: AnthropicEvent) => {
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return onContentBlockDelta(state, { ...event, delta: decoded.value })
return Effect.succeed(onContentBlockStart(state, event))
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") {
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed(onMessageDelta(state, { ...event, delta: decoded.value }))
}
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
+18 -6
View File
@@ -212,7 +212,11 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -646,14 +650,22 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
}
if (event.exception) {
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
code: event.exception.type,
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
}),
})
}
@@ -704,7 +716,7 @@ export const protocol = Protocol.make({
reasoningSignatures: {},
}),
step,
onHalt: (state) => Effect.succeed(onHalt(state)),
onHalt,
},
})
@@ -82,7 +82,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
out.push({ [eventType]: parsed })
}
return [cursor, out] as const
})
+5 -30
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -125,7 +125,6 @@ const GeminiContentPart = Schema.Union([
GeminiFunctionCallPart,
GeminiFunctionResponsePart,
])
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
const GeminiContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
@@ -133,11 +132,6 @@ const GeminiContent = Schema.Struct({
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
const GeminiResponseContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(Schema.Unknown)),
})
const GeminiSystemInstruction = Schema.Struct({
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
})
@@ -206,7 +200,7 @@ const GeminiUsage = Schema.Struct({
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: optionalNull(GeminiResponseContent),
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
})
@@ -228,7 +222,6 @@ const GeminiEvent = Schema.Struct({
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -605,21 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const input of candidate.content.parts ?? []) {
if (
ProviderShared.isRecord(input) &&
!("text" in input) &&
!("inlineData" in input) &&
!("functionCall" in input) &&
!("functionResponse" in input)
)
continue
const decoded = decodeGeminiContentPart(input)
if (Option.isNone(decoded))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const part = decoded.value
for (const part of candidate.content.parts ?? []) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -712,13 +691,9 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: (state) => Effect.succeed(finish(state)),
onHalt: finish,
},
})
@@ -154,12 +154,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
} satisfies CheckpointValue,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
},
}
}),
+86 -179
View File
@@ -79,60 +79,10 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
)
const OpenResponsesFileSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("file_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
queries: Schema.optional(Schema.Array(Schema.String)),
results: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesCodeInterpreterCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("code_interpreter_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
code: optionalNull(Schema.String),
container_id: optionalNull(Schema.String),
outputs: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesMCPCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("mcp_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
server_label: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
output: optionalNull(Schema.String),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
export const HostedToolItem = Schema.Union([
OpenResponsesWebSearchCall,
OpenResponsesFileSearchCall,
OpenResponsesCodeInterpreterCall,
OpenResponsesMCPCall,
])
export type HostedToolItem = Schema.Schema.Type<typeof HostedToolItem>
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
@@ -161,6 +111,7 @@ export const InputItem = Schema.Union([
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
@@ -173,17 +124,10 @@ export const InputItem = Schema.Union([
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -341,10 +285,8 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -353,7 +295,6 @@ export const Event = Schema.StructWithRest(
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
}),
@@ -373,7 +314,7 @@ export const Event = Schema.StructWithRest(
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
@@ -383,7 +324,6 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
@@ -399,10 +339,10 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
@@ -584,7 +524,10 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
@@ -607,7 +550,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const hostedToolItems = new Set<string>()
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
@@ -642,6 +586,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -662,29 +611,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const hosted =
part.result.type !== "json"
? undefined
: Schema.is(HostedToolItem)(part.result.value)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
hostedToolItems.add(id)
}
continue
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
}
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
if (reference) hostedToolReferences.add(reference)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -709,16 +653,21 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
return input
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const instructions = ProviderShared.joinText(request.system)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(instructions ? { instructions } : {}),
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -734,18 +683,11 @@ const lowerOptions = (request: LLMRequest) => {
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
export const resolveParallelToolCalls = (request: LLMRequest) => {
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
if (configured !== undefined) return configured
const disabled = request.toolChoice?.disableParallelToolUse
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
@@ -866,9 +808,6 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -944,23 +883,25 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
[
...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
]
}
@@ -1015,21 +956,31 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: "can-conclude",
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
NO_EVENTS,
events,
]
}
@@ -1037,24 +988,12 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
if (event.type === "response.function_call_arguments.done" && final === undefined)
return [state, NO_EVENTS] satisfies StepResult
if (final !== undefined && !final.startsWith(tool.input))
return [
{ ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) },
NO_EVENTS,
] satisfies StepResult
const delta = final === undefined ? event.delta : final.slice(tool.input.length)
if (!delta) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
event.item_id,
delta,
event.delta,
`${state.name} tool argument delta is missing its tool call`,
)
if (ToolStream.isError(result)) return yield* result
@@ -1092,19 +1031,18 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const id = item.id ?? item.call_id
const tools = state.tools[id]
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, id, {
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
@@ -1152,50 +1090,30 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const reconciled =
event.type === "response.completed"
? yield* Effect.reduce(
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
!id ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
)
},
)
: ([state, NO_EVENTS] satisfies StepResult)
const current = reconciled[0]
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(current, {
? providerMetadata(state, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1242,11 +1160,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1272,6 +1186,7 @@ export const step = (state: ParserState, input: Event) => {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1288,17 +1203,9 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1331,10 +1238,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
export const protocol = Protocol.make({
+52 -285
View File
@@ -7,10 +7,7 @@ import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
InvalidProviderOutputReason,
LLMEvent,
ProviderInternalReason,
UnknownProviderReason,
Usage,
type FinishReason,
type FinishReasonDetails,
@@ -54,12 +51,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -141,7 +133,6 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -227,22 +218,16 @@ const OpenAIChatChoice = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.StructWithRest(
Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const OpenAIChatEvent = Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@@ -265,7 +250,6 @@ export interface ParserState {
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
readonly requireFinishReason: boolean
}
// =============================================================================
@@ -278,21 +262,14 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -305,8 +282,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
type: "function",
function: {
name: part.name,
@@ -364,9 +341,8 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -383,7 +359,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part, options))
toolCalls.push(lowerToolCall(part))
continue
}
}
@@ -393,17 +369,15 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined)
return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
@@ -431,7 +405,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -441,7 +415,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -457,13 +431,11 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
})
@@ -484,37 +456,12 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const requireReasoning =
request.model.compatibility?.requireReasoning ??
(request.model.compatibility?.reasoningField !== undefined ||
request.model.provider === "deepseek" ||
request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") ||
modelID.includes("deepseek"))
const reasoningField = request.model.compatibility?.reasoningField
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -557,16 +504,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, lowering)
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
}
flushImages()
return messages
@@ -583,122 +528,11 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.promptCacheKey(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
@@ -717,19 +551,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
@@ -743,13 +566,11 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -759,7 +580,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request),
}
})
@@ -769,40 +590,14 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
// Streaming parsers are small state machines: every event returns a new state
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
// because OpenAI streams JSON arguments across multiple deltas.
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
new AIError({
module: ADAPTER,
method: "stream",
body: ProviderShared.encodeJson(event),
reason,
})
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "error":
return yield* finishReasonError(
event,
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
)
case "network_error":
return yield* finishReasonError(
event,
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
)
case "stop":
case "end":
return "stop" as const
case "length":
return "length" as const
case "content_filter":
return "content-filter" as const
case "function_call":
case "tool_calls":
return "tool-calls" as const
default:
return "unknown" as const
}
})
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "stop") return "stop"
if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown"
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
@@ -915,20 +710,16 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error) {
const body = ProviderShared.encodeJson(event)
if (event.error)
return yield* new AIError({
module: ADAPTER,
method: "stream",
body,
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
})
}
const events: LLMEvent[] = []
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
@@ -937,11 +728,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
rawFinishReason !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
@@ -961,11 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
@@ -1037,19 +821,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
if (ToolStream.isError(result)) return yield* result
tools = result.tools
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat tool call delta is missing id or name",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
@@ -1072,27 +851,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningEmitted,
latestToolIndex,
nextToolIndex,
requireFinishReason: state.requireFinishReason,
},
events,
] as const
})
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
route: ADAPTER,
}),
})
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const toolCallEvents =
state.finishReason === undefined && Object.keys(state.tools).length > 0
? (yield* ToolStream.finishAll(ADAPTER, state.tools)).events
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason
@@ -1101,7 +869,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -1115,7 +883,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
events.push(...toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
})
}
// =============================================================================
// Protocol And OpenAI Route
@@ -1144,7 +912,6 @@ export const protocol = Protocol.make({
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step,
onHalt: finishEvents,
@@ -17,7 +17,6 @@ export const route = Route.make({
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
+10 -51
View File
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -32,40 +32,6 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("computer_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_preview_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
revised_prompt: optionalNull(Schema.String),
}),
[JsonObject],
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesToolChoice = Schema.Union([
@@ -75,7 +41,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -89,21 +54,21 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// items keep generic validation because every hosted tool mints its own
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so items keep generic validation.
"hosted-tool": [],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
@@ -140,18 +105,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -160,7 +121,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
} satisfies OpenAIResponsesBody
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
@@ -201,11 +162,9 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -246,7 +205,7 @@ export const route = Route.make({
endpoint,
auth,
transport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
defaults: { providerOptions: { store: false } },
})
export * as OpenAIResponses from "./openai-responses.js"
+11 -24
View File
@@ -28,10 +28,10 @@ export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const promptCacheKey = (request: LLMRequest): string | undefined => {
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
@@ -210,9 +210,10 @@ export const errorText = (error: unknown) => {
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
@@ -220,23 +221,9 @@ export const sseFraming = (
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Sse.Event[] = []
return {
output,
parser: Sse.makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message)
return [state, state.output.splice(0)] as const
}),
),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
@@ -56,6 +56,7 @@ export const StreamOptions = Schema.Struct({
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
@@ -1,64 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
* Licensed under the MIT License; see partial-json.ts for the complete notice.
*/
/**
* allow partial strings like `"hello \u12` to be parsed as `"hello `
*/
export const STR = 0b000000001
/**
* allow partial numbers like `123.` to be parsed as `123`
*/
export const NUM = 0b000000010
/**
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
*/
export const ARR = 0b000000100
/**
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
*/
export const OBJ = 0b000001000
/**
* allow `nu` to be parsed as `null`
*/
export const NULL = 0b000010000
/**
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
*/
export const BOOL = 0b000100000
/**
* allow `Na` to be parsed as `NaN`
*/
export const NAN = 0b001000000
/**
* allow `Inf` to be parsed as `Infinity`
*/
export const INFINITY = 0b010000000
/**
* allow `-Inf` to be parsed as `-Infinity`
*/
export const _INFINITY = 0b100000000
export const INF = INFINITY | _INFINITY
export const SPECIAL = NULL | BOOL | INF | NAN
export const ATOM = STR | NUM | SPECIAL
export const COLLECTION = ARR | OBJ
export const ALL = ATOM | COLLECTION
/**
* Control what types you allow to be partially parsed.
* The default is to allow all types to be partially parsed, which in most cases is the best option.
*/
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
export default Allow
@@ -1,282 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js
*
* MIT License
*
* Copyright (c) 2023 Promplate Dev Team
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Schema } from "effect"
import { Allow } from "./partial-json-options.js"
export * from "./partial-json-options.js"
export class PartialJSON extends Error {}
export class MalformedJSON extends Error {}
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
const input = jsonString.trim()
if (!input) throw new Error(`${jsonString} is empty`)
try {
return decodeJson(input)
} catch {}
const repaired = repairJSON(input)
if (repaired !== input) {
try {
return decodeJson(repaired)
} catch {}
}
try {
return _parseJSON(input, allowPartial)
} catch (error) {
if (repaired !== input) return _parseJSON(repaired, allowPartial)
throw error
}
}
const repairJSON = (input: string) => {
let repaired = ""
let quoted = false
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (!quoted) {
repaired += character
if (character === '"') quoted = true
continue
}
if (character === '"') {
repaired += character
quoted = false
continue
}
if (character === "\\") {
const next = input[index + 1]
if (next === "u" && /^[0-9a-fA-F]{4}$/.test(input.slice(index + 2, index + 6))) {
repaired += input.slice(index, index + 6)
index += 5
continue
}
if (next !== undefined && '"\\/bfnrtu'.includes(next)) {
repaired += `\\${next}`
index++
continue
}
repaired += "\\\\"
continue
}
const code = character.charCodeAt(0)
repaired += code <= 0x1f ? `\\u${code.toString(16).padStart(4, "0")}` : character
}
return repaired
}
const _parseJSON = (jsonString: string, allow: number) => {
const length = jsonString.length
let index = 0
const markPartialJSON = (message: string): never => {
throw new PartialJSON(`${message} at position ${index}`)
}
const throwMalformedError = (message: string): never => {
throw new MalformedJSON(`${message} at position ${index}`)
}
const parseAny = (): unknown => {
skipBlank()
if (index >= length) markPartialJSON("Unexpected end of input")
if (jsonString[index] === '"') return parseStr()
if (jsonString[index] === "{") return parseObj()
if (jsonString[index] === "[") return parseArr()
if (
jsonString.substring(index, index + 4) === "null" ||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
) {
index += 4
return null
}
if (
jsonString.substring(index, index + 4) === "true" ||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
) {
index += 4
return true
}
if (
jsonString.substring(index, index + 5) === "false" ||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
) {
index += 5
return false
}
if (
jsonString.substring(index, index + 8) === "Infinity" ||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
) {
index += 8
return Infinity
}
if (
jsonString.substring(index, index + 9) === "-Infinity" ||
(Allow._INFINITY & allow &&
1 < length - index &&
length - index < 9 &&
"-Infinity".startsWith(jsonString.substring(index)))
) {
index += 9
return -Infinity
}
if (
jsonString.substring(index, index + 3) === "NaN" ||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
) {
index += 3
return NaN
}
return parseNum()
}
const parseStr = (): string => {
const start = index
let escape = false
index++
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
escape = jsonString[index] === "\\" ? !escape : false
index++
}
if (jsonString.charAt(index) === '"') {
try {
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
} catch (error) {
throwMalformedError(String(error))
}
}
if (Allow.STR & allow) {
try {
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
} catch {
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
}
}
return markPartialJSON("Unterminated string literal")
}
const parseObj = (): Record<string, unknown> => {
index++
skipBlank()
const object: Record<string, unknown> = {}
try {
while (jsonString[index] !== "}") {
skipBlank()
if (index >= length && Allow.OBJ & allow) return object
const key = parseStr()
skipBlank()
index++
try {
Object.defineProperty(object, key, {
value: parseAny(),
enumerable: true,
configurable: true,
writable: true,
})
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
}
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.OBJ & allow) return object
return markPartialJSON("Expected '}' at end of object")
}
index++
return object
}
const parseArr = (): unknown[] => {
index++
const array: unknown[] = []
try {
while (jsonString[index] !== "]") {
array.push(parseAny())
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.ARR & allow) return array
return markPartialJSON("Expected ']' at end of array")
}
index++
return array
}
const parseNum = (): unknown => {
if (index === 0) {
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
try {
return decodeJson(jsonString)
} catch (error) {
if (Allow.NUM & allow) {
try {
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
} catch {}
}
throwMalformedError(String(error))
}
}
const start = index
if (jsonString[index] === "-") index++
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
try {
return decodeJson(jsonString.substring(start, index))
} catch (error) {
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
try {
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
} catch {
throwMalformedError(String(error))
}
}
}
const skipBlank = () => {
while (index < length && " \n\r\t".includes(jsonString[index])) index++
}
return parseAny()
}
export const parse = parseJSON
+21 -24
View File
@@ -1,10 +1,8 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
type StreamKey = string | number
const parsePartialInput = Option.liftThrowable(parse)
/**
* One pending streamed tool call. Providers emit the tool identity and JSON
@@ -64,39 +62,38 @@ const inputDelta = (tool: PendingTool, text: string) =>
id: tool.id,
name: tool.name,
text,
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
event,
]
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -179,7 +176,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and recover incomplete local arguments when needed.
* from state, and return either a call or a non-executable local input error.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
+1 -41
View File
@@ -1,52 +1,15 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const XAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("x_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
@@ -72,10 +35,7 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: XAIResponsesBody,
from: fromRequest,
},
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
-58
View File
@@ -1,58 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cerebras")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "cerebras-chat",
provider: id,
endpoint: { baseURL: profiles.cerebras.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
-2
View File
@@ -3,7 +3,6 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
export * as Azure from "./azure.js"
export * as Cerebras from "./cerebras.js"
export * as Cloudflare from "./cloudflare.js"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
export * as Google from "./google.js"
@@ -15,6 +14,5 @@ export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
+3 -1
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,10 +115,12 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
-58
View File
@@ -1,58 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("togetherai")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "togetherai-chat",
provider: id,
endpoint: { baseURL: profiles.togetherai.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
+1 -1
View File
@@ -42,7 +42,7 @@ const responsesRoute = Route.make({
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
defaults: { providerOptions: { store: false } },
})
const chatRoute = Route.make({
+7 -27
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
@@ -322,30 +321,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
const stream = Stream.suspend(() => {
let state = protocol.stream.initial(request)
const parsed = events.pipe(
Stream.mapEffect((event) =>
protocol.stream.step(state, event).pipe(
Effect.map(([next, output]) => {
state = next
return output
}),
),
),
Stream.flatMap(Stream.fromIterable),
)
const onHalt = protocol.stream.onHalt
return onHalt
? parsed.pipe(
Stream.concat(
Stream.suspend(() =>
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
),
),
)
: parsed
}).pipe(
const stream = events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
)
@@ -401,8 +382,7 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(resolveRequestOptions(request))
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
+2 -2
View File
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional effectful flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
/** Optional flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
}
/**
-2
View File
@@ -152,8 +152,6 @@ export const ToolInputDelta = Schema.Struct({
id: ToolCallID,
name: Schema.String,
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
-8
View File
@@ -153,16 +153,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.optional(Schema.Boolean),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
-12
View File
@@ -1,12 +0,0 @@
import { isRecord } from "./record.js"
export const sanitizeSurrogates = <T>(value: T): T => {
if (typeof value === "string") return value.toWellFormed() as T
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
if (value instanceof Uint8Array || value instanceof Error) return value
if (isRecord(value))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
) as T
return value
}
+1 -22
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -86,27 +86,6 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Vertex", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-4-8",
),
system: "You are concise.",
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+2 -69
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { LLM, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_completion_tokens: 30,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -247,73 +247,6 @@ describe("request option precedence", () => {
}),
)
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "hello \uD800 \u{1F600}",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
system: "system \uD800 \u{1F600}",
messages: [
Message.user("user \uDC00"),
Message.assistant([
Message.text("assistant \uD800"),
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
],
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [
{ role: "system", content: "system \uFFFD \u{1F600}" },
{ role: "user", content: "user \uFFFD" },
{
role: "assistant",
content: "assistant \uFFFD",
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
},
{ role: "tool", content: '{"output":"result \uFFFD"}' },
],
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "text", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-text",
"recordedAt": "2026-08-25T23:55:27.619Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":256}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"The\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" user says: \\\"Reply exactly with: Hello!\\\" So we must output exactly\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" \\\"Hello!\\\" with no extra characters, no formatting\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\". Ensure no extra spaces or new\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"lines? Probably just \\\"Hello!\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" Usually we output exactly that.\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"content\":\"Hello!\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":142,\"completion_tokens\":58,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":46},\"prompt_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.645281,\"queue_time\":0.003817115,\"prompt_time\":0.001587193,\"completion_time\":0.029805929,\"total_time\":0.036823272705078125}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "tool", "tool-call", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-tool-call",
"recordedAt": "2026-08-25T23:55:28.454Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":512}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"We\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\" need to call the function get_weather with city \\\"\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"Paris\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"},\"type\":\"function\",\"id\":\"3d860cefe\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"type\":\"function\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":174,\"completion_tokens\":37,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":13},\"prompt_tokens\":137,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.8019717,\"queue_time\":0.31196235,\"prompt_time\":0.005234764,\"completion_time\":0.020198402,\"total_time\":0.702225923538208}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -24,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
},
"response": {
"status": 200,
@@ -35,4 +29,4 @@
}
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -62,7 +62,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -18,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -25,7 +25,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
@@ -43,7 +43,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Allow, MalformedJSON, PartialJSON, parse } from "../src/protocols/utils/partial-json.js"
describe("partial JSON", () => {
test("parses complete JSON", () => {
expect(parse('{"key":"value","items":[1,true,null]}')).toEqual({
key: "value",
items: [1, true, null],
})
const object = parse('{"__proto__":{"safe":true}}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
})
test("parses partial strings", () => {
expect(parse('"hello')).toBe("hello")
expect(parse('"hello \\u12')).toBe("hello ")
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("repairs invalid escapes and raw control characters", () => {
expect(parse('{"path":"A\\H","text":"first\tsecond"}')).toEqual({
path: "A\\H",
text: "first\tsecond",
})
})
test("preserves prototype keys in partial objects", () => {
const object = parse('{"__proto__":{"safe":true}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
expect(Object.getPrototypeOf(object)).toBe(Object.prototype)
expect(object.__proto__).toEqual({ safe: true })
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
expect(parse('{"key":"', Allow.OBJ)).toEqual({})
expect(parse('{"key":"', Allow.OBJ | Allow.STR)).toEqual({ key: "" })
})
test("parses partial literals and numbers", () => {
expect(parse("nu", Allow.NULL)).toBeNull()
expect(parse("tr", Allow.BOOL)).toBe(true)
expect(parse("fa", Allow.BOOL)).toBe(false)
expect(parse("1e", Allow.NUM)).toBe(1)
})
test("distinguishes disallowed partial values from malformed values", () => {
expect(() => parse("[", Allow.STR)).toThrow(PartialJSON)
expect(() => parse("n", ~Allow.NULL)).toThrow(MalformedJSON)
})
test("rejects empty input", () => {
expect(() => parse(" ")).toThrow("is empty")
})
})
+2 -11
View File
@@ -26,8 +26,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
import("@opencode-ai/ai/providers/togetherai"),
import("@opencode-ai/ai/providers/cerebras"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -97,11 +95,7 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({
reasoningEffort: "low",
store: true,
include: ["reasoning.encrypted_content"],
})
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
@@ -291,10 +285,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses",
})
expect(responses.route.defaults.providerOptions).toEqual({
store: false,
include: ["reasoning.encrypted_content"],
})
expect(responses.route.defaults.providerOptions).toEqual({ store: false })
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -5,7 +5,6 @@ import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinit
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { GoogleVertexMessages } from "../../src/providers.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -19,21 +18,6 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
cache: "none",
}),
)
const vertexOpus48 = GoogleVertexMessages.configure({
accessToken: "test",
location: "global",
project: "test",
}).model("claude-opus-4-8")
const request = LLM.request({
id: "req_1",
model,
@@ -293,149 +277,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
])
}),
)
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
Message.assistant("Acknowledged."),
Message.user("Next step."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
{ role: "user", content: [{ type: "text", text: "Next step." }] },
])
}),
)
it.effect("keeps a terminal direct Anthropic system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
],
},
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("keeps an ordinary terminal Vertex system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [Message.user("Before."), Message.system("Operator update.")],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -723,65 +564,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("demotes unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
auth: Auth.bearer("test"),
})
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}),
)
it.effect("lets an explicit signature requirement override inference", () =>
Effect.gen(function* () {
const compatible = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
auth: Auth.header("x-api-key", "test"),
})
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -920,108 +702,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores unknown content block and delta variants", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "future_event", content_block: 42, delta: 42 },
{ type: "content_block_start", index: 0, content_block: { type: "future_block", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { text: "ignored" } },
{ type: "content_block_delta", index: 0, delta: { type: "future_delta", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "hidden" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized content block variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed recognized content delta variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed payloads on unrelated stream events", () =>
Effect.gen(function* () {
const events = [
{ type: "message_start", message: { usage: { input_tokens: 1 } }, delta: 42 },
{ type: "content_block_start", index: 0 },
{ type: "content_block_delta", index: 0 },
{ type: "content_block_stop", index: 0, content_block: { type: "text", text: 42 } },
{ type: "message_delta" },
{ type: "message_delta", delta: { stop_reason: 42 } },
{ type: "message_stop", delta: { text: 42 } },
{ type: "error", error: { type: "overloaded_error", message: "busy" }, content_block: 42 },
]
yield* Effect.forEach(events, (event) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -1380,14 +1060,8 @@ describe("Anthropic Messages route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -475,14 +475,8 @@ describe("Bedrock Converse route", () => {
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
expect(events).toEqual([
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
@@ -491,7 +485,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
it.effect("emits malformed tool input as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -508,10 +502,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
id: "tool_1",
name: "lookup",
input: { query: "partial" },
raw: '{"query":"partial',
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
@@ -716,32 +710,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("futureEvent", { message: "Ignore this" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
])
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails unknown stream exceptions after message stop", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
exceptionFrame("futureException", { message: "A future provider failure" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const body = concat([
@@ -6,7 +6,6 @@ import { AmazonBedrockMantle } from "../../src/providers.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
const credentials = {
@@ -72,9 +71,7 @@ describe("Amazon Bedrock Mantle provider", () => {
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }] }), {
headers: { "content-type": "text/event-stream" },
})
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
-48
View File
@@ -906,54 +906,6 @@ describe("Gemini route", () => {
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Hello " },
{ executableCode: { language: "PYTHON", code: "print('ignored')" } },
{ text: "world" },
],
},
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.text).toBe("Hello world")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("rejects malformed recognized response parts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }],
}),
),
),
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -1,6 +1,5 @@
import * as Anthropic from "../../src/providers/anthropic.js"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
import * as Google from "../../src/providers/google.js"
import * as OpenAI from "../../src/providers/openai.js"
@@ -48,10 +47,11 @@ const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = TogetherAI.configure({
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
const together = OpenAICompatible.togetherai
.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
@@ -193,27 +193,8 @@ describeRecordedGoldenScenarios([
name: "TogetherAI Llama 3.3 70B",
prefix: "openai-compatible-chat",
model: together,
requires: ["TOGETHER_API_KEY"],
scenarios: [
{
id: "text",
cassette: "openai-compatible-chat/togetherai-streams-text",
prompt: "Reply with exactly: Hello!",
maxTokens: 20,
},
{ id: "tool-call", cassette: "openai-compatible-chat/togetherai-streams-tool-call" },
],
},
{
name: "Cerebras GPT OSS 120B",
prefix: "cerebras-chat",
model: cerebras,
requires: ["CEREBRAS_API_KEY"],
scenarios: [
{ id: "text", maxTokens: 256, temperature: false },
{ id: "tool-call", maxTokens: 512, temperature: false },
{ id: "tool-loop", maxTokens: 512, temperature: false, timeout: 30_000 },
],
requires: ["TOGETHER_AI_API_KEY"],
scenarios: ["text", "tool-call"],
},
{
name: "Groq Llama 3.3 70B",
@@ -1,151 +0,0 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
const together = yield* compileRequest(
LLM.request({
model: TogetherAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("llama"),
prompt: "Use a tool.",
generation: { maxTokens: 32 },
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
providerOptions: { store: true },
}),
)
expect(together.body).toMatchObject({
max_tokens: 32,
stream_options: { include_usage: true },
tools: [{ function: { name: "lookup" } }],
})
expect(together.body).not.toHaveProperty("max_completion_tokens")
expect(together.body).not.toHaveProperty("store")
expect(together.body.tools?.[0]?.function).not.toHaveProperty("strict")
const cerebras = yield* compileRequest(
LLM.request({
model: Cerebras.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("qwen"),
generation: { maxTokens: 48 },
messages: [
Message.user("Think first."),
Message.assistant([
{ type: "reasoning", text: "A deliberate thought." },
{ type: "text", text: "An answer." },
]),
Message.user("Continue."),
],
providerOptions: { store: true },
}),
)
expect(cerebras.body).toMatchObject({
max_tokens: 48,
messages: [
{ role: "user", content: "Think first." },
{ role: "assistant", content: "An answer.", reasoning: "A deliberate thought." },
{ role: "user", content: "Continue." },
],
})
expect(cerebras.body).not.toHaveProperty("max_completion_tokens")
expect(cerebras.body).not.toHaveProperty("store")
expect(cerebras.body.messages[1]).not.toHaveProperty("reasoning_content")
}),
)
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
const scenarios = [
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-primary",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-legacy",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: Cerebras.configure().model("qwen"),
env: { CEREBRAS_API_KEY: "cerebras-secret" },
token: "cerebras-secret",
url: "https://api.cerebras.ai/v1/chat/completions",
},
]
yield* Effect.forEach(scenarios, (scenario) =>
LLM.generate(LLM.request({ model: scenario.model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(scenario.url)
expect(request.headers.get("authorization")).toBe(`Bearer ${scenario.token}`)
return input.respond(
sseEvents(
{ id: "chatcmpl_fixture", choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
{ id: "chatcmpl_fixture", choices: [{ delta: {}, finish_reason: "stop" }] },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: scenario.env }))),
Effect.tap((response) => Effect.sync(() => expect(response.text).toBe("Hello"))),
),
)
}),
)
})
+7 -167
View File
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are concise." },
@@ -55,8 +55,7 @@ describe("OpenAI Chat route", () => {
],
stream: true,
stream_options: { include_usage: true },
store: false,
max_completion_tokens: 20,
max_tokens: 20,
temperature: 0,
})
}),
@@ -85,28 +84,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -169,56 +146,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves observed reasoning fields when reasoning is required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }),
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning_text" } },
},
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done", reasoning_content: "" },
])
}),
)
it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, {
compatibility: { reasoningField: "reasoning_text", requireReasoning: false },
}),
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done" },
])
}),
)
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -264,21 +191,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
@@ -413,7 +325,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -433,40 +345,10 @@ describe("OpenAI Chat route", () => {
tools: [],
stream: true,
stream_options: { include_usage: true },
store: false,
})
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
const models = [
model,
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
]
yield* Effect.forEach(models, (selected) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "Sunny" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
{ role: "tool", tool_call_id: id.slice(0, 40) },
])
}),
)
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
@@ -532,30 +414,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1304,14 +1162,8 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -1391,11 +1243,6 @@ describe("OpenAI Chat route", () => {
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ finish_reason: "tool_calls" }],
})
}),
)
@@ -1409,7 +1256,6 @@ describe("OpenAI Chat route", () => {
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
)
const input = LLMRequest.update(request, {
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
@@ -1417,14 +1263,8 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
tools: [
{
type: "function",
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
},
],
tool_choice: "required",
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -158,29 +158,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
Effect.gen(function* () {
const prepare = (provider: string, baseURL: string, id: string) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
prompt: "Use a tool.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
expect(current.body).toMatchObject({ tool_stream: true })
const legacy = yield* Effect.all(
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
),
)
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -203,7 +180,7 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -227,7 +204,6 @@ describe("OpenAI-compatible Chat route", () => {
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
strict: false,
},
},
],
@@ -238,135 +214,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("normalizes tool call IDs for the selected model family", () =>
Effect.gen(function* () {
const longID = `call_${"a".repeat(48)}`
const cases = [
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
]
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: item.model }),
messages: [
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: item.expected }] },
{ role: "tool", tool_call_id: item.expected },
])
}),
)
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("requires reasoning for DeepSeek models, providers, and endpoints unless explicitly overridden", () =>
Effect.gen(function* () {
const cases = [
{ id: "DeepSeek-V3", provider: "custom", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "deepseek", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "custom", baseURL: "https://API.DeepSeek.COM/v1", required: true },
{ id: "ordinary-model", provider: "custom", baseURL: "https://api.custom.test/v1", required: false },
{
id: "ordinary-model",
provider: "custom",
baseURL: "https://api.custom.test/v1",
compatibility: { requireReasoning: true, reasoningField: "reasoning" },
required: true,
field: "reasoning",
},
{
id: "deepseek-chat",
provider: "deepseek",
baseURL: "https://api.deepseek.com/v1",
compatibility: { requireReasoning: false },
required: false,
},
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: item.baseURL } })
.model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined })
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant("Hello"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
],
}),
)
const field = "field" in item ? item.field : "reasoning_content"
for (const message of prepared.body.messages.filter((message) => message.role === "assistant")) {
if (item.required) expect(message).toHaveProperty(field, "")
else expect(message).not.toHaveProperty(field)
}
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -482,106 +329,13 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("rejects a stream without a required finish reason", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
})
}),
)
it.effect("infers stop when finish reasons are optional", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { requireFinishReason: false } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: compatible })).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "stop" })
}),
)
it.effect("normalizes the end finish reason to stop", () =>
it.effect("treats an empty finish reason as terminal", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "end")))),
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end" })
}),
)
it.effect("classifies provider error finish reasons", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "network_error")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
message: "Provider reported a network error (finish_reason: network_error)",
})
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_fixture",
choices: [{ finish_reason: "network_error" }],
})
const generic = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
Effect.flip,
)
expect(generic.reason).toMatchObject({
_tag: "UnknownProvider",
message: "Provider reported an error (finish_reason: error)",
})
}),
)
it.effect("preserves explicit provider error events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
id: "chatcmpl_error",
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
trace_id: "trace_1",
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_error",
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
trace_id: "trace_1",
})
}),
)
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
Effect.gen(function* () {
const filtered = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
)
const future = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
)
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
}),
)
@@ -601,11 +355,6 @@ describe("OpenAI-compatible Chat route", () => {
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
})
}),
)
})
@@ -47,31 +47,15 @@ describe("Open Responses-compatible route", () => {
})
expect(prepared.body).toEqual({
model: "example-model",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
instructions: "You are concise.",
input: [
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("allows callers to override stateless encrypted reasoning defaults", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
@@ -82,12 +66,10 @@ describe("Open Responses-compatible route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial instructions.",
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.instructions).toBe("Initial instructions.")
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
@@ -150,40 +132,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Read the file.",
tools: [
ToolDefinition.make({
name: "read",
description: "Read a file.",
inputSchema: { type: "object" },
}),
],
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
expect(prepared.body.parallel_tool_calls).toBe(false)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "read",
description: "Read a file.",
parameters: { type: "object" },
strict: false,
},
])
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
@@ -225,180 +173,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("replays only shared hosted tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
{ type: "file_search_call", id: "fs_1", queries: "not-an-array" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[3]) }] },
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"par' },
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"complete"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
})
}),
)
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" },
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
@@ -136,7 +136,6 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
instructions: "Call get_weather once, then reply exactly: Paris is sunny.",
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
@@ -168,8 +167,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
@@ -205,8 +204,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
File diff suppressed because it is too large Load Diff
@@ -190,21 +190,6 @@ describe("OpenRouter", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini"),
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
+7 -113
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { LLM, LLMEvent } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
@@ -14,24 +14,13 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
it.effect("extends the Open Responses baseline directly", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
it.effect("allows callers to opt out of encrypted reasoning", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { include: [] } }))
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toBeUndefined()
}),
)
@@ -70,106 +59,16 @@ describe("xAI Responses route", () => {
}),
)
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "x_search_1",
name: "x_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([item])
}),
)
it.effect("replays shared and xAI hosted tool items but rejects OpenAI extensions", () =>
Effect.gen(function* () {
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" },
{ type: "computer_call", id: "computer_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
items[1],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
@@ -181,11 +80,6 @@ describe("xAI Responses route", () => {
name: "x_search",
input: { query: "news" },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "json", value: item },
providerMetadata: { xai: { itemId: "x_search_1" } },
})
}),
)
@@ -78,31 +78,6 @@ describe("Z.ai Images", () => {
),
)
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({
prompt: "A red circle \uFFFD on a white background \u{1F600}",
metadata: { source: "default\uFFFD" },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("lets raw native options override aliases", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
-2
View File
@@ -20,7 +20,6 @@ type ScenarioInput =
readonly name?: string
readonly cassette?: string
readonly tags?: ReadonlyArray<string>
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
readonly timeout?: number
@@ -88,7 +87,6 @@ const runTarget = (target: TargetInput) => {
yield* runGoldenScenario(input.id, {
id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`,
model: target.model,
prompt: input.prompt,
maxTokens: input.maxTokens,
temperature: input.temperature,
})
+1 -2
View File
@@ -164,7 +164,6 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
export interface GoldenScenarioContext {
readonly id: string
readonly model: LanguageModel
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
}
@@ -299,7 +298,7 @@ const runGeneratedConversation = (context: GoldenScenarioContext, steps: Readonl
const runTextScenario = (context: GoldenScenarioContext) =>
runGeneratedConversation(context, [
user(context.prompt ?? "Reply exactly with: Hello!"),
user("Reply exactly with: Hello!"),
assistant.expectText(/^Hello!?$/, {
system: "You are concise.",
maxTokens: context.maxTokens ?? 40,
-32
View File
@@ -102,38 +102,6 @@ describe("AI.Usage", () => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
+1 -1
View File
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
+16 -81
View File
@@ -23,11 +23,9 @@ describe("ToolStream", () => {
expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
])
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
expect(finished).toEqual({
tools: {},
events: [
@@ -38,45 +36,6 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
@@ -132,7 +91,7 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
it.effect("finalizes malformed local input as a non-executable tool error", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -144,46 +103,18 @@ describe("ToolStream", () => {
expect(finished).toEqual({
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
)
it.effect("repairs malformed string escapes in final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: '{"path":"A\\H","text":"first\tsecond"}',
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
])
}),
)
it.effect("defaults unrecoverable local input to an empty object", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: "invalid",
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
])
}),
)
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
it.effect("preserves valid siblings when one parallel input is malformed", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
@@ -202,8 +133,12 @@ describe("ToolStream", () => {
events: [
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
{
type: "tool-input-error",
id: "call_invalid",
name: "lookup",
raw: '{"query":"partial',
},
],
})
}),
@@ -1,53 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_workspace_branch"
const directory = "C:/OpenCode/WorkspaceBranch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("selects a base branch for a new workspace", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_workspace_branch",
worktree: directory,
vcs: "git",
name: "workspace-branch",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
})
@@ -20,47 +20,45 @@ const messages = [
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const session = {
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
}
const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
pageMessages: () => ({ items: messages }),
}
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -79,19 +77,3 @@ test("reverts directly to the selected user message", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
test("hides revert actions in a child session", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
sessions: [
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
{ ...session, parentID: "ses_parent" },
],
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})
@@ -1,226 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/SessionQueueRegression"
const projectID = "proj_session_queue_regression"
const sessionID = "ses_session_queue_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
type InboxRow = {
id: string
sessionID: string
timeCreated: number
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
timeCreated: 1700000000000 + index,
type: "user",
payload: { text },
delivery: "queue",
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data,
} as OpenCodeEvent)
}
return {
rows,
prompts,
changes,
log,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
const row: InboxRow = {
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
}
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
inboxID: row.id,
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
})
},
}
}
async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
if (followUpBehavior) {
await page.addInitScript(
(behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })),
followUpBehavior,
)
}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "session-queue-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "queue-model" },
},
sessions: [
{
id: sessionID,
slug: "session-queue-regression",
projectID,
directory,
title: "Session queue regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
onInboxChange: mock.onInboxChange,
events: mock.events,
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
rows: page.locator('[data-component="session-queue-row"]'),
}
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, "queue")
await view.input.fill("queue this follow-up")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer")
await view.input.press("Enter")
await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible()
await view.input.fill("steer this correction")
await view.input.press("ControlOrMeta+Enter")
await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"])
await expect(view.input).toHaveText("")
})
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const first = view.rows.filter({ hasText: "first queued prompt" })
const third = view.rows.filter({ hasText: "third queued prompt" })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
})
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const view = await openSession(page, mock)
const original = view.rows.getByText("tighten the error copy", { exact: true })
await expect(original).toBeVisible()
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.fill("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:file:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -59,12 +59,12 @@ test("transitions a streaming shell from writing through command execution", asy
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
@@ -64,76 +64,6 @@ test("transitions shell and question through running error outcomes", async ({ p
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(failed, "patch", "running", { patchText: "Update src/failed.ts" }),
toolPart(
surviving,
"patch",
"running",
{ patchText: "Update src/surviving.ts" },
{
metadata: {
files: [
{
file: "src/surviving.ts",
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
additions: 1,
deletions: 1,
},
],
},
},
),
],
{ completed: false },
),
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
await timeline.send(
partUpdated(
toolPart(failed, "patch", "error", { patchText: "Update src/failed.ts" }, { error: "Patch failed visibly" }),
),
)
const failedRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${failed}"]`),
})
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
@@ -68,41 +68,6 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
function session(id: string, title: string) {
return {
id,
@@ -126,8 +126,6 @@ test("routes typing to the composer unless the open terminal is focused", async
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await composer.click()
await expect(composer).toBeFocused()
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
-34
View File
@@ -100,7 +100,6 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
@@ -175,39 +174,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
+1 -38
View File
@@ -21,7 +21,6 @@ export interface MockServerConfig {
cursor?: string
}
vcsDiff?: unknown[]
vcsBranches?: string[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
@@ -36,9 +35,6 @@ export interface MockServerConfig {
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
type MockStreamWindow = Window & {
@@ -297,7 +293,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
@@ -402,39 +397,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () =>
Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = record(ctx.payload) ? ctx.payload : {}
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof body.text === "string" ? body.text : "",
...(body.files === undefined ? {} : { files: body.files }),
...(body.agents === undefined ? {} : { agents: body.agents }),
...(body.skills === undefined ? {} : { skills: body.skills }),
...(body.metadata === undefined ? {} : { metadata: body.metadata }),
},
delivery: body.delivery === "queue" ? "queue" : "steer",
},
}
}),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
-20
View File
@@ -39,26 +39,6 @@ export type ComposerSelection = {
variant?: string
}
export type ComposerDelivery = "steer" | "queue"
// Contract between the composer and the session prompt queue. The session
// owns the queue (pending inbox items); the composer only asks which delivery
// a submit should use and delegates edit confirmation while a queued prompt
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
alternate: Accessor<ComposerDelivery | undefined>
// Inbox ID of the queued prompt currently loaded in the composer for editing.
editing: Accessor<string | undefined>
confirmEdit: (delivery: ComposerDelivery) => void
cancelEdit: () => void
// Loads the first queued prompt into the composer. Returns false when the queue is empty.
editFirst: () => boolean
}
export type ComposerSession = {
id: string
directory: string
+1 -2
View File
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
@@ -32,7 +32,6 @@ export function Composer(props: {
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+3 -59
View File
@@ -1,8 +1,7 @@
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { Button } from "@opencode-ai/ui/button"
@@ -46,7 +45,6 @@ export type ComposerEditorProps = {
modelControlsVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -179,15 +177,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit(mod ? { alternate: true } : undefined)
props.controller.submit()
}
}}
onKeyUp={updateCursor}
@@ -255,12 +248,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
</Show>
</Show>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -268,7 +255,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onSubmit={props.controller.submit}
onStop={props.controller.stop}
/>
</div>
@@ -704,49 +691,6 @@ export function ComposerEditorPopover(props: {
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
// disappears on its own when the current turn ends.
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
const i18n = useI18n()
const view = props.controller.view
const action = createMemo(() => {
const queue = view.submit.queue
if (!queue || !props.controller.canSubmit()) return undefined
if (queue.editing()) return "steer" as const
return queue.alternate()
})
const [button, setButton] = createSignal<HTMLButtonElement>()
const presence = createAnimatedPresence(action, () => button() ?? null)
return (
<Show when={presence.present() && presence.value()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
<span class="hidden sm:block">
<Keybind keys={props.keybind} variant="neutral" />
</span>
</Button>
</Tooltip>
)}
</Show>
)
}
export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
@@ -19,7 +19,6 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -38,8 +37,7 @@ export type ComposerEditorView = {
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
queue?: ComposerQueue
onSubmit: (options?: { alternate?: boolean }) => void
onSubmit: () => void
onStop: () => void
}
shell?: {
@@ -214,11 +212,6 @@ export function createComposerEditor(input: {
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
@@ -361,8 +354,8 @@ export function createComposerEditor(input: {
openShell() {
dispatch({ type: "mode.shell" })
},
submit(options?: { alternate?: boolean }) {
input.view.submit.onSubmit(options)
submit() {
input.view.submit.onSubmit()
dispatch({ type: "popover.close" })
},
stop() {
+4 -24
View File
@@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
@@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
@@ -80,11 +80,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
})
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () =>
composerPlaceholder(
mode(),
(key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
adapter.working() || (options?.queue?.count() ?? 0) > 0,
)
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -257,11 +253,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
notify: {
missingSelection: () =>
showToast({
@@ -369,18 +360,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
submit: {
stopping,
working: adapter.working,
queue: options?.queue,
onSubmit: (submitOptions) => {
const queue = options?.queue
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
void submission.submit(new Event("submit"), submitOptions)
},
onSubmit: () => void submission.submit(new Event("submit")),
onStop: () => void submission.stop(),
},
},
@@ -12,12 +12,4 @@ describe("Composer placeholder", () => {
test("uses the command and context hint in normal mode", () => {
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
})
test("uses the follow-up copy while a turn runs or prompts are queued", () => {
expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@")
})
test("keeps the shell placeholder while a turn runs", () => {
expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status")
})
})
-2
View File
@@ -1,9 +1,7 @@
export function composerPlaceholder(
mode: "normal" | "shell",
t: (key: string, params?: Record<string, string>) => string,
followUp?: boolean,
) {
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
+31 -36
View File
@@ -5,7 +5,7 @@ import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
@@ -21,7 +21,7 @@ type ComposerSubmission = {
text: string
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: ComposerDelivery
delivery: "steer"
}
type ComposerSubmitInput = {
@@ -33,7 +33,6 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -46,7 +45,7 @@ type ComposerSubmitInput = {
}
export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
const submit = async (event: globalThis.Event) => {
event.preventDefault()
const submission = createComposerSubmission({
@@ -57,7 +56,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
const value = readSubmission(input, submission.prompt, submission.context)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -114,10 +113,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
void sendCommand(session, value, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
@@ -161,7 +157,6 @@ function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
@@ -200,7 +195,7 @@ function readSubmission(
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: input.delivery?.(alternate) ?? "steer",
delivery: "steer",
}
}
@@ -279,8 +274,15 @@ async function sendCommand(
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
text: command.arguments,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
@@ -290,30 +292,23 @@ async function sendCommand(
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
const admission = {
+3 -2
View File
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, startTransition } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -49,7 +49,8 @@ export function createHomeController() {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
focusServer: (conn: ServerConnection.Any) =>
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
},
server: {
list: () => servers.visible,
+17 -5
View File
@@ -5,6 +5,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
@@ -42,6 +43,7 @@ export function createHomeSessionsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -68,9 +70,10 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
ctx.data.session.apply(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
Date.now(),
@@ -189,9 +192,15 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
return ctx.data.session
.remove(session.id)
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
@@ -207,6 +216,9 @@ export function createHomeSessionsController(home: HomeController) {
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
@@ -244,7 +256,7 @@ export function createHomeSessionsController(home: HomeController) {
data: {
records,
groups,
loading: () => sessionLoad.isPending,
loading: () => sessionLoad.isLoading,
searchRecords: allRecords,
},
session: {
@@ -12,7 +12,6 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
+19 -19
View File
@@ -1,6 +1,6 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show } from "solid-js"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -44,7 +44,6 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
loading: boolean
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
@@ -98,20 +97,22 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</Suspense>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -121,8 +122,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading}
<Suspense
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -164,7 +164,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</Index>
</div>
</Show>
</Show>
</Suspense>
</div>
</section>
)

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