Compare commits

..
Author SHA1 Message Date
Aiden Cline e09a0493bb refactor(core): replace websocket flags with a single transport preference
Drop Model.capabilities.responsesWebsockets, which provider plugins
precomputed from route facts, and replace the websocket booleans on
Model.Info, Provider.Info and their config entries with
transport?: "http" | "websocket".

The route now decides whether it has a WebSocket channel. Omitted uses
the channel when offered and HTTP otherwise; "http" never offers the
session executor; an explicit "websocket" on a route without a channel
logs a warning via the new WebSocketChannelExecutor.unavailable hook and
falls back to HTTP.
2026-09-10 20:52:36 -05:00
627 changed files with 11671 additions and 29333 deletions
+52 -21
View File
@@ -25,7 +25,7 @@ on:
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
permissions:
id-token: write
@@ -33,7 +33,7 @@ permissions:
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
jobs:
version:
@@ -168,7 +168,7 @@ jobs:
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
@@ -191,7 +191,7 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli-macos
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
@@ -199,7 +199,7 @@ jobs:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -221,7 +221,7 @@ jobs:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
@@ -276,9 +276,10 @@ jobs:
sign-cli-windows:
needs:
- sign-cli-macos
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -291,8 +292,15 @@ jobs:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-macos
path: packages/cli/dist
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
@@ -307,9 +315,9 @@ jobs:
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
@@ -325,9 +333,9 @@ jobs:
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
@@ -337,17 +345,40 @@ jobs:
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-electron:
needs:
- version
- sign-cli-windows
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+2 -2
View File
@@ -8,9 +8,9 @@
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
+35 -38
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.2",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -82,7 +82,6 @@
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"core-js": "3.50.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -112,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.2",
"version": "1.18.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -176,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.2",
"version": "1.17.13",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +215,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -252,7 +251,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -279,7 +278,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -296,7 +295,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -320,7 +319,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +339,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -412,7 +411,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -464,7 +463,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -501,7 +500,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -517,7 +516,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -536,7 +535,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.2",
"version": "0.0.0",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -549,7 +548,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.2",
"version": "0.0.0",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -563,7 +562,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.2",
"version": "0.0.0",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -578,7 +577,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -617,7 +616,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.2",
"version": "0.0.0",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -647,7 +646,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.2",
"version": "1.17.11",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -662,7 +661,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.2",
"version": "1.17.11",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -686,7 +685,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -707,7 +706,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -729,7 +728,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -764,7 +763,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.2",
"version": "1.17.13",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -784,7 +783,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -818,7 +817,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -837,7 +836,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -883,7 +882,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.2",
"version": "0.0.0",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -897,7 +896,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -932,7 +931,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -967,7 +966,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.2",
"version": "1.18.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1000,7 +999,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.2",
"version": "1.18.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1041,7 +1040,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.2",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3703,8 +3702,6 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+15 -43
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
APP=opencode
SOURCE_APP=opencode
APP=opencode2
MUTED='\033[0;2m'
RED='\033[0;31m'
@@ -23,7 +22,7 @@ Options:
Examples:
curl -fsSL https://opencode.ai/v2/install | bash
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
./install --binary /path/to/opencode
./install --binary /path/to/opencode2
EOF
}
@@ -202,9 +201,9 @@ else
filename="cli-$target-$specific_version.tgz"
url="https://registry.npmjs.org/$package_name/-/$filename"
binary_name="$SOURCE_APP"
binary_name="$APP"
if [ "$os" = "windows" ]; then
binary_name="$SOURCE_APP.exe"
binary_name="$APP.exe"
fi
fi
@@ -231,7 +230,12 @@ check_version() {
installed_version="${installed_version##* }"
installed_version="${installed_version#v}"
print_message info "${MUTED}Installed version: ${NC}$installed_version."
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
else
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
exit 0
fi
fi
}
@@ -336,46 +340,15 @@ download_and_install() {
fi
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
local installed_binary="$APP"
if [ "$os" = "windows" ]; then
installed_binary="$APP.exe"
fi
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
chmod 755 "$INSTALL_DIR/$installed_binary"
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/$binary_name"
rm -rf "$tmp_dir"
}
install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
local installed_binary="$APP"
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
esac
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
chmod 755 "$INSTALL_DIR/$installed_binary"
}
install_legacy_shim() {
local shim_os="${os:-}"
if [[ -z "$shim_os" ]]; then
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
esac
fi
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
if [[ "$shim_os" == "windows" ]]; then
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
@echo off
"%~dp0opencode.exe" %*
exit /b %errorlevel%
EOF
return
fi
cat > "$INSTALL_DIR/opencode2" <<'EOF'
#!/bin/sh
exec "$(dirname "$0")/opencode" "$@"
EOF
chmod 755 "$INSTALL_DIR/opencode2"
cp "$binary_path" "${INSTALL_DIR}/$APP"
chmod 755 "${INSTALL_DIR}/$APP"
}
if [ -n "$binary_path" ]; then
@@ -384,7 +357,6 @@ else
check_version
download_and_install
fi
install_legacy_shim
add_to_path() {
@@ -481,7 +453,7 @@ echo -e ""
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
echo -e ""
echo -e "cd <project> ${MUTED}# Open directory${NC}"
echo -e "opencode ${MUTED}# Run command${NC}"
echo -e "opencode2 ${MUTED}# Run command${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
echo -e ""
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
}
}
+3 -3
View File
@@ -2,15 +2,15 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.2",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+1 -2
View File
@@ -122,14 +122,13 @@ Keep provider facades small and explicit:
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
reasoningEffort: "high",
})
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "1.17.20",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -1708,10 +1708,7 @@ export const transport = <
}
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
// Always request interleaved thinking. The API accepts the header on any
// model and ignores it where unsupported, while manual-thinking models need
// it for thinking between tool calls.
const betas: string[] = ["interleaved-thinking-2025-05-14"]
const betas: string[] = []
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
const replaysCompaction = body.messages.some((message) =>
message.content.some((block) => block.type === "compaction"),
@@ -106,7 +106,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
const BedrockToolSpec = Schema.Struct({
toolSpec: Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
description: Schema.String,
inputSchema: Schema.Struct({
json: JsonObject,
}),
@@ -222,7 +222,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
description: tool.description,
inputSchema: { json: inputSchema },
},
})
@@ -18,6 +18,7 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -26,7 +27,6 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,7 +163,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -179,7 +178,8 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
}),
execute: (prepared, request, runtime, executeOptions) =>
Effect.gen(function* () {
if (!executeOptions?.webSocket || !prepared.channel) return yield* http.execute(prepared.http, request, runtime)
if (!executeOptions?.webSocket || !prepared.channel)
return yield* http.execute(prepared.http, request, runtime, executeOptions)
let fallbackHttp: HttpContext | undefined
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
@@ -6,6 +6,7 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -14,19 +15,12 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -133,26 +127,22 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -191,7 +181,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
responseID,
request,
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output?.length
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
@@ -205,4 +195,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
export const OpenResponsesContinuation = { driver } as const
+5 -34
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -400,39 +401,10 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
@@ -683,8 +655,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
// Replayed text is a finished input item, even if generation was cut short.
status: "completed",
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
-4
View File
@@ -1,10 +1,6 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactionOperations } from "./route/client.js"
/**
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
* reads; every other key is a request option for the route's protocol.
*/
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
+6 -16
View File
@@ -1,4 +1,3 @@
import { Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AlibabaChat } from "../protocols/alibaba-chat.js"
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
@@ -7,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -35,9 +34,9 @@ export type Config = Location &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = Location &
ProviderPackage.Settings &
Options & {
ProviderPackage.Settings & {
readonly apiKey?: string
readonly providerOptions?: Options
}
const hosts = new Map<string, string>([
@@ -83,13 +82,8 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -121,11 +115,7 @@ export const responsesModel: ProviderPackage.Definition<
function fromSettings(input: Settings<Config["providerOptions"]>) {
const { body, ...rest } = input
return configure({
...rest,
http: body === undefined ? undefined : { body },
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
})
return configure({ ...rest, http: body === undefined ? undefined : { body } })
}
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
@@ -1,10 +1,9 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -23,25 +22,26 @@ export type Config = RouteDefaultsInput & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
})
const chatRoute = OpenAIChat.route.with({
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
@@ -108,29 +105,18 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const fromSettings = ({
apiKey,
auth,
baseURL,
body,
credentials,
headers,
profile,
region,
topP,
...providerOptions
}: Settings) =>
const fromSettings = (settings: Settings) =>
configure({
apiKey,
auth,
baseURL,
credentials,
generation: topP === undefined ? undefined : { topP },
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
profile,
providerOptions,
region,
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
+3 -4
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -19,13 +19,13 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export const routes = [AnthropicMessages.route]
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -63,20 +59,17 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, authToken, baseURL, body, headers, provider, ...providerOptions },
settings,
) => {
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider ?? id),
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
}
+10 -13
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -21,12 +21,12 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
@@ -54,18 +54,15 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, authToken, baseURL, body, headers, ...providerOptions },
settings,
) => {
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
}
+13 -23
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -28,12 +28,12 @@ export type LanguageModelOptions = AzureURL &
export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
AzureURL & {
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
@@ -151,29 +151,19 @@ export const provider = {
configure,
}
const config = ({
apiKey,
apiVersion,
baseURL,
body,
headers,
queryParams,
resourceName,
useDeploymentBasedUrls,
...providerOptions
}: Settings): Config => {
const config = (settings: Settings): Config => {
const common = {
apiKey,
apiVersion,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
useDeploymentBasedUrls,
apiKey: settings.apiKey,
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "baseten-chat",
@@ -48,16 +48,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Baseten from "./baseten.js"
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "cerebras-chat",
@@ -52,14 +52,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -27,19 +27,15 @@ export type LanguageModelOptions = GatewayURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
GatewayURL & {
readonly apiKey?: string
readonly gatewayApiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -89,25 +85,14 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const {
accountId: _,
apiKey,
baseURL: _url,
body,
gatewayApiKey,
gatewayId: _id,
headers,
...providerOptions
} = settings
return configure({
apiKey,
gatewayApiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
gatewayApiKey: settings.gatewayApiKey,
baseURL: baseURL(settings),
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
}
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -21,18 +21,14 @@ export type LanguageModelOptions = WorkersAIURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
WorkersAIURL & {
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -63,15 +59,13 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const { accountId: _, apiKey, baseURL: _url, body, headers, ...providerOptions } = settings
return configure({
apiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: baseURL(settings),
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
}
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "deepinfra-chat",
@@ -55,14 +55,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
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)
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "deepseek-chat",
@@ -52,16 +52,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as DeepSeek from "./deepseek.js"
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "fireworks-chat",
@@ -48,16 +48,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Fireworks from "./fireworks.js"
+19 -24
View File
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
const route = Route.make({
id: "google-vertex-chat",
@@ -37,8 +37,7 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -74,19 +73,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -25,14 +25,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
const route = Route.make({
id: "google-vertex-messages",
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
throw new Error("Google Vertex Messages does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -105,17 +105,16 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
settings,
) => {
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
const route = Route.make({
id: "google-vertex-responses",
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
throw new Error("Google Vertex Responses does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -77,17 +77,16 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
settings,
) => {
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -1,10 +1,8 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -37,18 +35,12 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
throw new Error("Google Vertex requires a project when baseURL is not configured")
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+13 -22
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -26,7 +26,6 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
GeminiProviderOptionsInput &
(
| { readonly accessToken?: string; readonly apiKey?: never }
| { readonly accessToken?: never; readonly apiKey?: string }
@@ -34,6 +33,7 @@ export type Settings = ProviderPackage.Settings &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: GeminiProviderOptionsInput
}
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -124,22 +121,16 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined && accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(apiKey === undefined ? { accessToken: accessToken } : { apiKey: apiKey }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
+11 -14
View File
@@ -20,11 +20,11 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
Gemini.ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
@@ -57,16 +57,13 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export const image = provider.image
+11 -14
View File
@@ -26,11 +26,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
const Options = Schema.Struct({
includeReasoning: Schema.optional(Schema.Boolean),
@@ -103,16 +103,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Groq from "./groq.js"
+11 -11
View File
@@ -79,11 +79,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
const responsesRoute = Route.make({
id: "meta-responses",
@@ -169,13 +169,13 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProvid
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
fromSettings(settings).messages(modelID)
function fromSettings({ apiKey, baseURL, body, headers, ...providerOptions }: Settings) {
function fromSettings(settings: Settings) {
return configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
})
}
+11 -11
View File
@@ -40,11 +40,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
@@ -127,14 +127,14 @@ export const provider = configure()
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export const messages = provider.messages
+6 -9
View File
@@ -3,14 +3,11 @@ import { MiniMax } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
MiniMax.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).chat(modelID)
@@ -5,12 +5,12 @@ export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
MiniMax.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+11 -14
View File
@@ -14,11 +14,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export const route = MistralChat.route
export const routes = [route]
@@ -39,16 +39,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Mistral from "./mistral.js"
+11 -14
View File
@@ -42,11 +42,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
@@ -133,16 +133,13 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Moonshot from "./moonshot.js"
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
Moonshot.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
Moonshot.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
@@ -16,12 +16,12 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export const routes = [OpenAICompatibleResponses.route]
@@ -48,13 +48,13 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
settings,
) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
+13 -16
View File
@@ -14,12 +14,12 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const routes = [OpenAICompatibleChat.route]
@@ -45,17 +45,14 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
export * as OpenAICompatible from "./openai-compatible.js"
+17 -26
View File
@@ -57,14 +57,14 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -116,28 +116,19 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = ({
apiKey,
baseURL,
body,
headers: given,
organization,
project,
queryParams,
...providerOptions
}: Settings): Config => {
const config = (settings: Settings): Config => {
const headers = {
...(organization === undefined ? {} : { "OpenAI-Organization": organization }),
...(project === undefined ? {} : { "OpenAI-Project": project }),
...given,
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
}
return {
apiKey,
baseURL,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}
}
+11 -11
View File
@@ -77,11 +77,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenRouterProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
@@ -191,12 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
+11 -14
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = Route.make({
id: "togetherai-chat",
@@ -52,14 +52,11 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
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)
+11 -15
View File
@@ -20,11 +20,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: XAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
XAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type { XAIImageOptions } from "../protocols/xai-images.js"
@@ -41,10 +41,6 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
@@ -110,13 +106,13 @@ export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, { apiKey, baseURL, body, headers, ...providerOptions }) =>
>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
+11 -14
View File
@@ -23,11 +23,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const chatRoute = Route.make({
id: "zai-coding-chat",
@@ -80,16 +80,13 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
ZAICodingPlan.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInpu
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
settings,
) =>
ZAICodingPlan.configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+11 -14
View File
@@ -17,11 +17,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput
}
export type Settings = ProviderPackage.Settings &
ChatOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
@@ -70,16 +70,13 @@ export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+1 -5
View File
@@ -23,7 +23,6 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -129,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
+2 -1
View File
@@ -87,8 +87,9 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
execute: (prepared, _request, runtime) =>
execute: (prepared, _request, runtime, options) =>
Effect.gen(function* () {
if (options?.webSocket?.unavailable) yield* options.webSocket.unavailable
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
return {
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
@@ -6,6 +6,8 @@ export interface WebSocketChannelExecutor {
readonly execute: (
exchange: WebSocketChannelExchange,
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
/** Runs when the route has no WebSocket channel for this request and carries it over HTTP instead. */
readonly unavailable?: Effect.Effect<void>
}
export interface WebSocketChannelExecution {
-13
View File
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
+1 -7
View File
@@ -23,13 +23,7 @@ export interface DispatchResult extends ToolSettlement {
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
const tool = tools[name]
if (!tool)
return Effect.succeed(
result(call, {
type: "error",
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
}),
)
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
if (!tool.execute)
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
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
@@ -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\",\"status\":\"completed\",\"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\":\"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.\"}"
},
{
"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\",\"status\":\"completed\",\"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\":\"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.\"}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
+20 -26
View File
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -188,13 +185,13 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1/",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
reasoningEffort: "high" as const,
providerOptions: { reasoningEffort: "high" as const },
}
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
expect(deepinfra.route.id).toBe("deepinfra-chat")
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
})
@@ -210,7 +207,7 @@ describe("provider package entrypoints", () => {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { custom: true },
reasoningEffort: "high",
providerOptions: { reasoningEffort: "high" },
})
expect(selected.provider).toBe(provider.id)
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
@@ -231,11 +228,11 @@ describe("provider package entrypoints", () => {
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
usage: true,
providerOptions: { usage: true },
})
const xai = XAI.model("grok-4", {
...settings,
reasoningEffort: "high",
providerOptions: { reasoningEffort: "high" },
})
for (const selected of [openrouter, xai]) {
@@ -269,8 +266,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
reasoningEffort: "low",
store: true,
providerOptions: { reasoningEffort: "low", store: true },
})
expect(String(selected.provider)).toBe("example")
@@ -296,7 +292,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
effort: "low",
providerOptions: { effort: "low" },
})
expect(String(selected.provider)).toBe("example")
@@ -316,7 +312,7 @@ describe("provider package entrypoints", () => {
const Anthropic = await import("@opencode/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
thinking: { type: "adaptive" },
providerOptions: { thinking: { type: "adaptive" } },
})
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
@@ -326,7 +322,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
).toThrow("Anthropic-compatible providers require a baseURL")
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -341,10 +337,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -410,7 +406,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
thinkingConfig: { thinkingBudget: 1_024 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
expect(selected.route.id).toBe("gemini")
@@ -494,45 +490,43 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
})
})
+1 -7
View File
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -69,9 +69,7 @@ for (const model of [
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe(
"existing-beta,interleaved-thinking-2025-05-14,compact-2026-01-12",
)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
@@ -32,9 +32,7 @@ for (const [id, enabled] of [
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
)
expect(prepared.request.headers["anthropic-beta"]).toBe(
enabled
? "existing-beta,interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01"
: "existing-beta,interleaved-thinking-2025-05-14",
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
)
}),
)
@@ -55,9 +53,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
}
}),
@@ -279,52 +279,6 @@ describe("Bedrock Converse route", () => {
})
}),
)
;["", " \t\r\n"].forEach((description) => {
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}),
],
}),
)
expect(prepared.body.toolConfig.tools).toEqual([
{
toolSpec: {
name: "lookup",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
])
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
}),
)
})
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
Effect.gen(function* () {
const description = " \tLookup data.\n"
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object" },
}),
],
}),
)
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
@@ -1504,13 +1458,7 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "open-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "open-responses",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
+6 -4
View File
@@ -34,10 +34,12 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
providerOptions: {
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
},
}),
{ provider: "custom-groq" },
)
+11 -2
View File
@@ -36,13 +36,21 @@ it.effect("Meta composes baseline protocols with provider-owned endpoints and de
it.effect("Meta Responses stays on HTTP when a WebSocket executor is supplied", () =>
Effect.gen(function* () {
let unavailable = 0
for (const baseURL of ["https://api.meta.ai/v1", "https://gateway.example/v1"]) {
const response = yield* LLMClient.generate(
LLM.request({
model: Meta.configure({ apiKey: "fixture", baseURL }).responses("muse-spark-1.3"),
prompt: "Hello",
}),
{ webSocket: { execute: () => Effect.die("Meta must not execute WebSocket requests") } },
{
webSocket: {
execute: () => Effect.die("Meta must not execute WebSocket requests"),
unavailable: Effect.sync(() => {
unavailable += 1
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -76,6 +84,7 @@ it.effect("Meta Responses stays on HTTP when a WebSocket executor is supplied",
expect(response.text).toBe("Hello")
expect(response.finishReason.normalized).toBe("stop")
}
expect(unavailable).toBe(2)
}),
)
@@ -87,7 +96,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
reasoningEffort: "future-effort",
providerOptions: { reasoningEffort: "future-effort" },
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
@@ -99,7 +99,7 @@ describe("native OpenAI-compatible providers", () => {
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
reasoningEffort: "high",
providerOptions: { reasoningEffort: "high" },
}
const selected = provider.configure(settings).model("test-model")
expect(selected.provider).toBe(provider.id)
@@ -143,7 +143,7 @@ describe("native OpenAI-compatible providers", () => {
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
store: true,
providerOptions: { store: true },
}),
)
@@ -168,7 +168,7 @@ describe("native OpenAI-compatible providers", () => {
]),
Message.user("Continue."),
],
store: true,
providerOptions: { store: true },
}),
)
@@ -206,7 +206,7 @@ describe("native OpenAI-compatible providers", () => {
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
reasoningEffort: "high",
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
@@ -1,78 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -1,120 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const model of [
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
]) {
describe(`${model.route.protocol} message replay`, () => {
const key = model.route.providerMetadataKey ?? "openresponses"
it.effect("marks assistant text completed regardless of stored status", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
Message.make({
role: "assistant",
providerMetadata: { [key]: { status } },
content: [
{
type: "text",
text: `Saved ${index}`,
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
},
{
type: "text",
text: `Final ${index}`,
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
},
],
}),
),
Message.make({
role: "user",
content: [{ type: "text", text: "Continue" }],
providerMetadata: { [key]: { status: "incomplete" } },
}),
],
}),
)
expect(prepared.body.input).toEqual([
...[0, 1, 2, 3].flatMap((index) => [
{
type: "message",
role: "assistant",
id: `msg_${index}`,
phase: "commentary",
status: "completed",
content: [{ type: "output_text", text: `Saved ${index}` }],
},
{
type: "message",
role: "assistant",
id: `msg_final_${index}`,
phase: "final_answer",
status: "completed",
content: [{ type: "output_text", text: `Final ${index}` }],
},
]),
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_partial", status: "in_progress" },
},
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_partial",
status: "incomplete",
content: [{ type: "output_text", text: "The next step is" }],
},
},
{
type: "response.incomplete",
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
},
),
),
),
)
expect(response.finishReason.normalized).toBe("length")
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
id: "msg_partial",
status: "completed",
content: [{ type: "output_text", text: "The next step is" }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
})
}
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -299,27 +299,23 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
id: `history_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Long." }],
},
{
type: "message",
id: "provider_value/with+symbols",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Opaque." }],
},
{
type: "message",
role: "assistant",
status: "completed",
content: [
{ type: "output_text", text: "No suffix." },
{ type: "output_text", text: "No prefix." },
@@ -860,7 +856,6 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
@@ -90,11 +90,7 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -102,7 +98,6 @@ const continuationDriver = (
request,
message,
base: base(message),
continuation,
})
}
@@ -411,7 +406,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -586,54 +581,52 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
),
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
@@ -687,47 +680,45 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
),
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
@@ -926,58 +917,6 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
@@ -1199,9 +1138,13 @@ describe("OpenAI Responses route", () => {
},
]
const unavailable = yield* Ref.make(0)
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
webSocket: {
execute: () => Effect.die("unexpected WebSocket request"),
unavailable: Ref.update(unavailable, (value) => value + 1),
},
}).pipe(
Effect.provide(
dynamicResponse((input) =>
@@ -1215,6 +1158,7 @@ describe("OpenAI Responses route", () => {
),
),
)
expect(yield* Ref.get(unavailable)).toBe(cases.length)
}),
)
@@ -2179,7 +2123,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
@@ -2262,7 +2205,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_commentary",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
@@ -2270,7 +2212,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_final",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
@@ -2278,7 +2219,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_null",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -3409,19 +3349,14 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Before." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -3685,14 +3620,12 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
{
type: "message",
id: `message_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "World" }],
},
{
+2 -110
View File
@@ -1,18 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } 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"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -20,35 +13,6 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -198,78 +162,6 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
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" } }
+4 -12
View File
@@ -237,16 +237,13 @@ describe("LLMClient tools", () => {
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
message: "Unknown tool: missing",
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: {
type: "error",
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
},
result: { type: "error", value: "Unknown tool: missing" },
providerMetadata,
}),
])
@@ -715,17 +712,12 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
expect(toolError?.message).toBe(
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
)
expect(toolError?.message).toContain("Unknown tool")
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
type: "tool-result",
id: "call_1",
name: "missing_tool",
result: {
type: "error",
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
},
result: { type: "error", value: "Unknown tool: missing_tool" },
})
}),
)
@@ -1,103 +0,0 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
host.dataset.testid = "browser-pane-fixture"
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
document.body.appendChild(host)
function Fixture() {
const [store, setStore] = createStore({
session: "Alpha",
mounted: true,
visible: true,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
url: `https://${name.toLowerCase()}.example/`,
loading: false,
canGoBack: false,
canGoForward: false,
generation: 0,
}))
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
const registrations = new Map<string, BrowserPaneRegistration>(
tabs.map((tab) => [
tab.title,
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
close: () => undefined,
},
]),
)
const browser: ReturnType<typeof createSessionBrowser> = {
available: () => true,
attached: () => !!registrations.get(store.session),
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
}
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
<p>Selected session: {store.session}</p>
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
<For each={["Alpha", "Beta", "Empty"]}>
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
{(tab) => (
<div
data-testid={`native-${tab.title}`}
data-visible={!!store.layouts[tab.title]?.visible}
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
>
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
</div>
)}
</For>
</>
)
}
return render(
() => (
<LanguageProvider locale="en">
<UiI18nBridge>
<DialogProvider>
<Fixture />
</DialogProvider>
</UiI18nBridge>
</LanguageProvider>
),
host,
)
}
@@ -1,44 +0,0 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await page.evaluate(async (fixture) => {
const { mountBrowserPane } = await import(fixture)
mountBrowserPane()
}, fixture)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
})
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Empty", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -1,27 +0,0 @@
# Session-export load benchmark
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
From `packages/app` in PowerShell:
```powershell
$env:PLAYWRIGHT_BUILD = '1'
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
```
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
@@ -1,227 +0,0 @@
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { stressSessionHref } from "./timeline-test-helpers"
import { startChromeTrace } from "../chrome-trace"
const file = process.env.LAGGY_SESSION_FILE
const session = file
? (JSON.parse(readFileSync(file, "utf8")) as {
info: {
id: string
projectID: string
title: string
model?: { id: string; providerID: string }
location: { directory: string }
time: { created: number; updated: number }
}
messages: SessionMessageInfo[]
})
: undefined
const sourceID = "ses_laggy_benchmark_source"
const sourceMessageID = "msg_laggy_benchmark_source"
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
const viewport = { width: 1440, height: 900 }
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
for (const mode of ["compact", "ungrouped"] as const) {
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
if (!session) return
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
const lastID = session.messages.findLast((message) => message.type === "user")!.id
const lastText = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)!
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
const requests: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
if (process.env.LAGGY_HTTP === "1")
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
})
const detail = Object.fromEntries(
timelineCategories.map((category) => [
category,
{
...timelinePresets[2].value[category],
placement: mode === "compact" ? "grouped" : "separate",
},
]),
)
const directory = session.info.location.directory
if (process.env.LAGGY_HTTP !== "1")
await mockOpenCodeServer(page, {
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session-benchmark",
time: session.info.time,
sandboxes: [],
},
provider: {
all: [
{
id: model.providerID,
name: model.providerID,
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
},
],
connected: [model.providerID],
default: { providerID: model.providerID, modelID: model.id },
},
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
pageMessages: (id, limit, before) => {
if (id !== session.info.id)
return {
items: [
{
id: sourceMessageID,
type: "user",
text: "Benchmark source",
time: { created: session.info.time.created },
},
],
}
if (history === "full") return { items: session.messages }
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
const start = Math.max(0, end - limit)
return {
items: session.messages.slice(start, end),
cursor: start > 0 ? session.messages[start].id : undefined,
}
},
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await page.addInitScript(
({ detail, directory, server, sessionIDs, dirBase64 }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
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(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{
detail,
directory,
server: process.env.PLAYWRIGHT_BASE_URL!,
sessionIDs: [sourceID, session.info.id],
dirBase64: base64Encode(directory),
},
)
await page.goto(stressSessionHref(sourceID))
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
expect(requests).toEqual([sourceID])
const startedAt = new Date().toISOString()
const samples = []
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
for (const [iteration, phase] of phases.entries()) {
const before = requests.length
const stopTrace =
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
? await startChromeTrace(page, `laggy-${history}-${mode}`)
: undefined
const result = await measureSessionSwitch(page, {
destinationIDs: session.messages.map((message) => message.id),
sourceIDs: [sourceMessageID],
lastID,
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
requireBottomAnchor: true,
href: stressSessionHref(session.info.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
},
})
await stopTrace?.()
await expectSessionTitle(page, session.info.title)
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
await expect(
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
).toHaveCount(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
if (phase === "warm") expect(requests.length - before).toBe(0)
samples.push({
iteration,
phase,
messageRequests: requests.length - before,
messageResources: await page.evaluate((sessionID) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
)
.map((entry) => ({
limit: Number(new URL(entry.name).searchParams.get("limit")),
startMs: entry.startTime - start,
durationMs: entry.duration,
transferBytes: entry.transferSize,
}))
}, session.info.id),
...result,
})
if (iteration === phases.length - 1) {
mkdirSync(output, { recursive: true })
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
break
}
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
expect(errors).toEqual([])
const result = {
pair: testInfo.repeatEachIndex,
startedAt,
mode,
history,
file,
messages: session.messages.length,
viewport,
browser: page.context().browser()!.version(),
detail,
samples,
}
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
report(
{ samples },
{
mode,
sampling: "cold/warm pair in one browser context",
pair: testInfo.repeatEachIndex,
messages: session.messages.length,
viewport,
data: history === "full" ? "full exported history" : "paginated exported history",
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
},
)
})
}
@@ -1,63 +0,0 @@
export {}
type Pair = {
mode: "compact" | "ungrouped"
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
}
const directory = Bun.argv[2]
if (!directory) throw new Error("Pass the directory containing session-load pairs")
const pairs = await Promise.all(
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
const cold = pair.samples.find((sample) => sample.phase === "cold")
const warm = pair.samples.find((sample) => sample.phase === "warm")
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
throw new Error(`Expected a completed cold/warm pair in ${file}`)
return {
mode: pair.mode,
cold: cold.firstCorrectObservedMs,
warm: warm.firstCorrectObservedMs,
requests: warm.messageRequests,
}
}),
)
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
const result = ["compact", "ungrouped"].flatMap((mode) => {
const selected = pairs.filter((pair) => pair.mode === mode)
if (!selected.length) return []
return [
{
mode,
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
},
]
})
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
console.table(
result.map((row) => ({
mode: row.mode,
pairs: row.cold.n,
coldMedianMs: Math.round(row.cold.median * 10) / 10,
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
warmMedianMs: Math.round(row.warm.median * 10) / 10,
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
warmMaxMs: Math.round(row.warm.max * 10) / 10,
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
})),
)
function stats(values: number[]) {
const sorted = values.toSorted((left, right) => left - right)
return {
n: sorted.length,
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
min: sorted[0],
max: sorted.at(-1)!,
}
}
@@ -1,37 +0,0 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
})
await page.addInitScript((directory) => {
// Safari 16.6 has neither API. Remove them before the web entry runs.
Reflect.deleteProperty(Map, "groupBy")
Reflect.deleteProperty(Promise, "withResolvers")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
await page.getByRole("button", { name: "Add project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(row).toBeVisible()
})
@@ -59,9 +59,8 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -93,7 +92,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -114,10 +113,6 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -142,19 +137,12 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -164,7 +152,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -179,18 +167,9 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,12 +104,18 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,26 +2,20 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
File diff suppressed because it is too large Load Diff
@@ -1,476 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
import { openWithDirection } from "../utils/direction"
const directory = "/workspace/summary-project"
const workspace = "/workspace/existing-worktree"
const createdWorkspace = "/workspace/created-worktree"
const draftID = "draft_summary"
const secondDraftID = "draft_summary_other"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const draftPath = `/new-session?draftId=${draftID}`
for (const rtl of [false, true]) {
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
page,
}, testInfo) => {
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const view = await page.locator('[data-component="new-session"]').boundingBox()
if (!button || !view) return false
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
})
.toBe(true)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!button || !project || !server) return
return {
top: project.y - button.y - button.height,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 12, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
})
for (const [name, item] of [
["MCP", "summary-mcp"],
["Plugins", "project-plugin"],
["Skills", "summary-skill"],
["LSP", "typescript"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
}
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
await expect
.poll(async () => {
const menu = await worktreeMenu.boundingBox()
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
if (!menu || !panel) return false
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
})
.toBe(true)
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
await page.keyboard.press("Enter")
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await mcp.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
expect(mock.status.get(directory)).toBe("connected")
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
).toBeVisible()
})
}
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
await openDraft(page, "main", { git: false })
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
await summary.getByRole("button", { name: "MCP", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
).toBeEnabled()
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
await expect(menu).toHaveCSS("opacity", "1")
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await expect
.poll(() =>
page.evaluate(
() =>
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
)?.mcp?.states,
),
)
.toEqual({ "summary-mcp": false })
await page.goto(`/new-session?draftId=${secondDraftID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).toBeChecked()
await page.goto(draftPath)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "worktree", directory },
{ type: "mcp", directory: createdWorkspace, enabled: false },
{ type: "session", directory: createdWorkspace },
{ type: "prompt", directory: createdWorkspace },
])
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
expect(mock.status.get(directory)).toBe("connected")
})
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
try {
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL(draftPath)
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
expect(mock.prompts).toEqual([])
} finally {
release.resolve()
}
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "mcp", directory, enabled: false },
{ type: "session", directory },
{ type: "prompt", directory },
])
})
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
mock.state.holdDirectory = directory
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
try {
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeEnabled()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
expect(mock.status.get(directory)).toBe("connected")
} finally {
release.resolve()
}
})
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
const mock = await openDraft(page, "create")
mock.state.fail = true
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
expect(mock.prompts).toEqual([])
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
mock.state.fail = false
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(mock.status.get(createdWorkspace)).toBe("disabled")
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
})
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
const mock = await openDraft(page, "create")
mock.status.set(createdWorkspace, "needs_auth")
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
return route.fulfill({
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: createdWorkspace },
data: {
id: "summary-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
const popup = page.waitForEvent("popup")
await page.locator('[data-action="composer-submit"]').click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
expect(attempts).toEqual([createdWorkspace])
expect(mock.prompts).toEqual([])
mock.status.set(createdWorkspace, "connected")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
vcs: options.git === false ? undefined : "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const sessions: ReturnType<typeof currentSession>[] = []
const status = new Map<string, string>([
[directory, "connected"],
[workspace, "disabled"],
[createdWorkspace, "connected"],
])
const calls: { type: string; directory: string; enabled?: boolean }[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
project,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "summary-model" },
},
pageMessages: () => ({ items: [] }),
onPrompt(input) {
const session = sessions.find((session) => session.id === input.sessionID)
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
calls.push({ type: "prompt", directory: session.location.directory })
prompts.push(input)
},
})
if (options.git === false) {
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
)
}
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
if (route.request().method() === "POST") {
const enabled = url.pathname.endsWith("/connect")
calls.push({ type: "mcp", directory: target, enabled })
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
status.set(target, enabled ? "connected" : "disabled")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "summary-mcp",
integrationID: "summary-oauth",
status: { status: status.get(target) ?? "connected" },
},
],
},
})
})
await page.route(
(url) => url.pathname === "/api/location",
(route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: project.id, directory, canonical: directory },
},
}),
)
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
const id = target === directory ? "project-plugin" : "workspace-plugin"
return route.fulfill({
json: {
location: { directory: target },
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "summary-skill",
name: "summary-skill",
location: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
}),
)
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
calls.push({ type: "worktree", directory })
project.sandboxes.push(createdWorkspace)
return route.fulfill({ json: { directory: createdWorkspace } })
},
)
await page.route(
(url) => url.pathname === "/api/session",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
calls.push({ type: "session", directory: body.location.directory })
const session = currentSession(
{ ...body, projectID: project.id, title: "Created summary session" },
body.location.directory,
)
sessions.push(session)
return route.fulfill({ json: { data: session } })
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
if (options.git !== false) {
await expect(
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
).toBeVisible()
}
return { calls, prompts, status, state }
}
@@ -22,10 +22,6 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
@@ -48,28 +44,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await expect(search).toBeFocused()
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(search).toBeFocused()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(selected).toBeFocused()
await page.keyboard.press("Enter")
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await page.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
})
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
@@ -1,109 +0,0 @@
import { expect, test } from "@playwright/test"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/project-autosave"
const project = {
id: "proj_autosave",
canonical: directory,
name: "Autosave project",
icon: { color: "orange" },
commands: { start: "echo setup" },
sandboxes: [],
time: { created: 1, updated: 1 },
}
test.use({ viewport: { width: 1280, height: 900 } })
for (const field of [
{
name: "color",
label: "",
initial: "orange",
next: "blue",
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
},
{
name: "name",
label: "Project name",
initial: project.name,
next: "Renamed project",
patches: [{ name: "Renamed project" }, { name: project.name }],
},
{
name: "startup script",
label: "Worktree startup script",
initial: project.commands.start,
next: "bun install",
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
},
]) {
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server } }],
projects: {
local: [{ worktree: directory, expanded: true }],
[server]: [{ worktree: directory, expanded: true }],
},
}),
)
},
{ directory, server },
)
const pending = Promise.withResolvers<void>()
const patches: unknown[] = []
await page.route(`**/api/project/${project.id}`, async (route) => {
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
patches.push(patch)
if (patches.length === 1) await pending.promise
await route.fulfill({ json: { ...project, ...patch } })
})
await page.goto("/settings")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const edit = async (value: string) => {
if (field.name === "color") {
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
return
}
const input = settings.getByRole("textbox", { name: field.label, exact: true })
await input.fill(value)
await input.blur()
}
const first = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
)
await edit(field.next)
await first
await edit(field.initial)
expect(patches).toEqual([field.patches[0]])
pending.resolve()
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
expect(patches).toEqual(field.patches)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: project.name, exact: true }).click()
if (field.name === "color") {
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
return
}
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
})
}
@@ -1,129 +0,0 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -20,7 +20,6 @@ test("session settings use the remote server context", async ({ page }) => {
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
preferencesUnavailable: true,
})
await configureServers(page)
@@ -35,10 +34,8 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -66,21 +63,9 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -88,7 +73,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -326,7 +311,6 @@ type MockServerOptions = {
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
preferencesUnavailable?: boolean
}
async function mockServers(
@@ -418,11 +402,6 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell")
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/websearch/provider")
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -1,6 +1,5 @@
import { base64Encode } from "@opencode/util/encode"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { expect, test, type Route } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -14,7 +13,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
project: {
@@ -60,7 +58,6 @@ test("opens and searches project files inline", async ({ page }) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
events: () => events.splice(0),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
@@ -129,35 +126,6 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
const missingReadPattern = "**/api/fs/read/README.md*"
const missingRead = (route: Route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
_tag: "FileNotFoundError",
path: "README.md",
message: "File not found: README.md",
}),
})
await page.route(missingReadPattern, missingRead)
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
events.push(filesystemEvent("README.md", "unlink"))
await missingResponse
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
await expect(missingTab).toBeVisible()
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
await page.unroute(missingReadPattern, missingRead)
events.push(filesystemEvent("README.md", "add"))
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
await expect(restoredTab).toBeVisible()
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
@@ -187,16 +155,6 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
})
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
return {
id: `evt_${file}_${event}`,
created: 1,
type: "filesystem.changed",
location: { directory },
data: { file, event },
}
}
function fileNode(path: string) {
return {
name: path,

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