Compare commits

...

23 Commits

Author SHA1 Message Date
Kit Langton 8e81a7797e fix(session): adapt compaction to async processor 2026-03-27 23:24:17 -04:00
Kit Langton cdfdb91403 refactor(session): tighten processor abort handling 2026-03-27 23:24:17 -04:00
Kit Langton 9b4eba1f98 refactor(session): effectify session processor 2026-03-27 23:24:17 -04:00
Kit Langton 366a94bf33 refactor(session): clean up processor retry flow 2026-03-27 23:24:17 -04:00
Kit Langton 53e556f8ee refactor(session): rename lastThree to recentParts, add retry comment 2026-03-27 23:24:17 -04:00
Kit Langton c07dae5a2d refactor(session): preserve processor effect follow-up 2026-03-27 23:24:17 -04:00
Kit Langton 95908b4631 refactor(session): convert SessionProcessor to Effect internals
Replace the while(true) + for-await + try/catch pattern with:
- Stream.fromAsyncIterable + Stream.runForEachWhile for event consumption
- Recursive Effect for retry logic (preserves SessionRetry.delay backoff)
- Effect.ensuring for cleanup guarantees
- Effect.catch for error classification

process() still returns Promise externally (via Effect.runPromise) so
callers don't change. Event handling and cleanup logic unchanged.
2026-03-27 23:24:16 -04:00
Kit Langton 4b0d2bf4b3 refactor(session): extract handleEvent and cleanup from SessionProcessor
Pure structural refactor — no Effect yet. Extracts the switch statement
into a standalone handleEvent function and the cleanup section into a
cleanup function. ProcessorContext object shares mutable state via
getters/setters. Zero behavior change (verified by two review agents).
2026-03-27 23:24:16 -04:00
Adam f736116967 fix(app): more startup efficiency (#19454) 2026-03-28 01:26:57 +00:00
Luke Parker 82fc493520 feat(ci): use Azure Artifact Signing for Windows releases (#15201) 2026-03-28 11:20:19 +10:00
Kit Langton 2145d97f18 refactor(session): effectify SessionCompaction service (#19459) 2026-03-28 01:09:14 +00:00
Sebastian f3997d8082 Single target plugin entrypoints (#19467) 2026-03-27 23:44:46 +00:00
opencode-agent[bot] 02b19bc3d7 chore: generate 2026-03-27 21:38:08 +00:00
Kit Langton 5cd54ec345 refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) 2026-03-27 17:37:07 -04:00
opencode-agent[bot] c8909908f5 chore: update nix node_modules hashes 2026-03-27 21:11:06 +00:00
James Long 4b9660b211 refactor(core): move more responsibility to workspace routing (#19455) 2026-03-27 16:33:56 -04:00
Kit Langton e5f0e813b6 refactor(session): effectify Session service (#19449) 2026-03-27 16:25:47 -04:00
Aiden Cline c33d9996f0 feat: AI SDK v6 support (#18433) 2026-03-27 15:24:30 -05:00
Sebastian 7a7643c86a no theme override in dev (#19456) 2026-03-27 20:21:15 +00:00
Aiden Cline 6f5b70e681 tweak: add additional overflow error patterns (#19446) 2026-03-27 15:19:51 -05:00
Sebastian ff13524a53 fix flaky plugin tests (no mock.module for bun) (#19445) 2026-03-27 20:55:03 +01:00
Kit Langton e973bbf54a fix(app): default file tree to closed with minimum width (#19426) 2026-03-27 14:11:50 -04:00
Kit Langton d36b38e4a6 fix(desktop-electron): match dev dock icon inset on macOS (#19429) 2026-03-27 17:32:05 +00:00
108 changed files with 4209 additions and 2789 deletions
+183 -1
View File
@@ -98,15 +98,129 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
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@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ 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
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ 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) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- 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@v4
with:
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-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -152,6 +266,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -190,6 +312,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,34 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -292,6 +438,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -326,6 +480,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -373,6 +544,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -411,6 +583,16 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
-54
View File
@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
+12 -3
View File
@@ -1,7 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
@@ -813,7 +820,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
])
}
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
@@ -846,7 +853,9 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
}
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin
-1
View File
@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",
@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
+63 -183
View File
@@ -142,9 +142,9 @@
"name": "@opencode-ai/console-function",
"version": "1.3.3",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
"@ai-sdk/openai-compatible": "1.0.1",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@hono/zod-validator": "catalog:",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
@@ -305,25 +305,25 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.82",
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.54",
"@ai-sdk/google-vertex": "3.0.106",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.21",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.80",
"@ai-sdk/google": "3.0.53",
"@ai-sdk/google-vertex": "4.0.95",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -337,7 +337,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1",
@@ -347,7 +347,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
@@ -358,7 +358,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.3",
"gitlab-ai-provider": "6.0.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -599,10 +599,10 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
},
"overrides": {
"@types/bun": "catalog:",
@@ -629,7 +629,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.124",
"ai": "6.0.138",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
@@ -673,51 +673,51 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.82", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yb1EkRCMWex0tnpHPLGQxoJEiJvMGOizuxzlXFOpuGFiYgE679NsWE/F8pHwtoAWsqLlylgGAJvJDIJ8us8LEw=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-E+wzGPSa/XHmajO3WtX8mtq0ewy04tsHSpU6/SGwqbiykwWba/emi7ayZ4ir89s5OzbAen2g7T9zZiEchMfkHQ=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-htT1Y7vBN0cRu/1pGnhx6DNH3xaNr0o0MjDkmii48X2+6S/WkOzVNtMjn7V3vLWEQIWNio5vw1hG/F43K8WLHA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qvh2yxL5zJS9RO/Bf12pyYBIDmn+9GR1hT6e28IYWQWnt2Xq0h9XGps6XagLAv3VYYFg8c/ozkWVd4kXLZ25HA=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4vOEekW4TAYVHN0qgiwoUOQZhguGwZBiEw8LDeUmpWBm07QkLRAtxYCaSoMiA4hZZojao5mj6NRGEBW1CnDPtg=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ee2At5jgV+SqC6nrtPq20iH7N/aN+O36LrA4gkzVM4cmhM7bvQKVkOXhC1XxG+wsYG6UZi3Nekoi8MEjNWuRrw=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K+1YprVMO8R6vTcNhqTqUWhOzX5V/hEY0pFx9KQL0/+MJjOgRi6DcOLoNBd7ONcjxYTyiFLRfk/0a/pHTtSgFA=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.35", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-inUq29XvSVDer6JIeOkwAmCFxOtHPU0OZEhwaWoe3PI59naHIW4RIFA9wppLLV5fJI9WQcAfDKy0ZHW9nV3UJw=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.40", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ARjygiBQtVSgNBp3Sag+Bkwn68ub+cZPC05UpRGG+VY8/Q896K2yU1j4I0+S1eU0BQW/9DKbRG04d9Ayi2DUmA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.106", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/google": "2.0.54", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f9sA66bmhgJoTwa+pHWFSdYxPa0lgdQ/MgYNxZptzVyGptoziTf1a9EIXEL3jiCD0qIBAg+IhDAaYalbvZaDqQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/google": "3.0.53", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xL44fHlTtDM7RLkMTgyqMfkfthA38JS91bbMaHItObIhte1PAIY936ZV1PLl/Z9A/oBAXjHWbXo5xDoHzB7LEg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1455,9 +1455,7 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2271,9 +2269,9 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
"ai": ["ai@6.0.138", "", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -3049,7 +3047,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4799,63 +4797,21 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.11", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -5329,16 +5285,6 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -5557,12 +5503,6 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -5739,16 +5679,6 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -5757,28 +5687,6 @@
"@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6211,20 +6119,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -6321,10 +6215,6 @@
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
@@ -6581,12 +6471,6 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -6639,10 +6523,6 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4XhUHjgqinKxOeT8K5hGAjpFA2vzOp8QpEg0uYCZwvg=",
"aarch64-linux": "sha256-X2YTNOpJocIkWkkfS8RnuDW+tvj4riHs7CXM+cS9iv0=",
"aarch64-darwin": "sha256-pN0rY+cpdW+6gNWeegVprdmhc2H72OZ9WxKDIs1fvJM=",
"x86_64-darwin": "sha256-l8+Yz/6UfSPJrdgfcqy/L2SvxN2i9Apv2R0B61rpEmw="
"x86_64-linux": "sha256-aqmdiQeFREbUfRi3YX+ot4+CjykDuJpxYQH54W3hxME=",
"aarch64-linux": "sha256-ykJp6rFFwXkfJpMRJheTw+r495Wpmx5nj2LKxgSSVDw=",
"aarch64-darwin": "sha256-xHGM1rLld8sqkY+lhvec7fWkPPajIE403viIcpsFnk4=",
"x86_64-darwin": "sha256-QkGtT76P9Kf2+Ny0rI4CwMrIFzRIXiZwi8KS2o+jECU="
}
}
+4 -4
View File
@@ -46,7 +46,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.37",
"ai": "5.0.124",
"ai": "6.0.138",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
@@ -113,8 +113,8 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
}
}
+24 -24
View File
@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(initialFontFamily).toContain("IBM Plex Mono")
expect(initialFontFamily).toContain("ui-monospace")
const next = "Test Mono"
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
})
.toMatchObject({
appearance: {
font: next,
mono: next,
},
})
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(initialFontFamily).toContain("Inter")
expect(initialFontFamily).toContain("ui-sans-serif")
const next = "Test Sans"
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
})
.toMatchObject({
appearance: {
uiFont: next,
sans: next,
},
})
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "Reset Mono",
mono: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
await expect
.poll(async () => {
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "",
mono: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("IBM Plex Mono")
expect(fontFamily).toContain("ui-monospace")
expect(fontFamily).not.toContain("Reset Mono")
})
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "Reset Sans",
sans: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
await expect
.poll(async () => {
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "",
sans: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("Inter")
expect(fontFamily).toContain("ui-sans-serif")
expect(fontFamily).not.toContain("Reset Sans")
})
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
return raw ? JSON.parse(raw) : null
}, settingsKey)
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click()
await code.clear()
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.font).toBe(mono)
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
expect(updatedSettings?.appearance?.mono).toBe(mono)
expect(updatedSettings?.appearance?.sans).toBe(sans)
await closeDialog(page, dialog)
await page.reload()
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.font).toBe(mono)
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
+11 -2
View File
@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
@@ -278,7 +283,11 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>
+82 -60
View File
@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
heartbeat = undefined
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
const start = () => {
if (started) return run
started = true
run = (async () => {
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()
})
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
+20
View File
@@ -72,10 +72,16 @@ function createGlobalSync() {
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
@@ -348,6 +354,20 @@ function createGlobalSync() {
}
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
}
void bootstrap()
})
@@ -43,8 +43,10 @@ function waitForPaint() {
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
setTimeout(() => {
clearTimeout(timer)
finish()
}, 0)
})
})
}
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
]
const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
if (loading) input.setStore("status", "partial")
const fast = [
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
seededPath
? Promise.resolve()
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
)
}),
),
]
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>
+31 -16
View File
@@ -13,7 +13,8 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
const DEFAULT_SIDEBAR_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -161,11 +162,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
tab: "changes",
}
})()
@@ -230,7 +231,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({
sidebar: {
opened: false,
width: DEFAULT_PANEL_WIDTH,
width: DEFAULT_SIDEBAR_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
@@ -243,8 +244,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
panelOpened: true,
},
fileTree: {
opened: true,
width: DEFAULT_PANEL_WIDTH,
opened: false,
width: DEFAULT_FILE_TREE_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
@@ -543,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {
@@ -628,32 +643,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
return
}
setStore("fileTree", "tab", tab)
},
open() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", (x) => !x)
+9 -1
View File
@@ -94,7 +94,11 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})
+20 -22
View File
@@ -32,8 +32,8 @@ export interface Settings {
}
appearance: {
fontSize: number
font: string
uiFont: string
mono: string
sans: string
}
keybinds: Record<string, string>
permissions: {
@@ -43,20 +43,18 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
const monoKey = "ibm-plex-mono"
const monoBase = monoFallback
const sansBase = sansFallback
function input(font: string | undefined, key?: string) {
if (!font || font === key || !font.trim()) return ""
return font
function input(font: string | undefined) {
return font ?? ""
}
function family(font: string) {
@@ -64,14 +62,14 @@ function family(font: string) {
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
}
function stack(font: string | undefined, base: string, key?: string) {
const value = input(font, key).trim()
function stack(font: string | undefined, base: string) {
const value = font?.trim() ?? ""
if (!value) return base
return `${family(value)}, ${base}`
}
export function monoInput(font: string | undefined) {
return input(font, monoKey)
return input(font)
}
export function sansInput(font: string | undefined) {
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
}
export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase, monoKey)
return stack(font, monoBase)
}
export function sansFontFamily(font: string | undefined) {
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
},
appearance: {
fontSize: 14,
font: "",
uiFont: "",
mono: "",
sans: "",
},
keybinds: {},
permissions: {
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
return {
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
setFont(value: string) {
setStore("appearance", "font", value.trim() ? value : "")
setStore("appearance", "mono", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
setUIFont(value: string) {
setStore("appearance", "uiFont", value.trim() ? value : "")
setStore("appearance", "sans", value.trim() ? value : "")
},
},
keybinds: {
+48 -2
View File
@@ -544,6 +544,8 @@ export default function Page() {
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@@ -718,7 +720,6 @@ export default function Page() {
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => {
void sync.session.sync(id)
})
@@ -730,13 +731,47 @@ export default function Page() {
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
})
}, 0)
})
}),
)
createEffect(
on(
() => {
const id = params.id
return [
sdk.directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -1640,6 +1675,15 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume,
})
createEffect(
on(
() => params.id,
(id) => {
if (!id) requestAnimationFrame(() => inputRef?.focus())
},
),
)
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
@@ -1649,6 +1693,8 @@ export default function Page() {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
+3 -3
View File
@@ -17,9 +17,9 @@
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
"@ai-sdk/openai-compatible": "1.0.1",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@hono/zod-validator": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
@@ -1,5 +1,25 @@
import { execFile } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import type { Configuration } from "electron-builder"
const execFileAsync = promisify(execFile)
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const signScript = path.join(rootDir, "script", "sign-windows.ps1")
async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
if (process.env.GITHUB_ACTIONS !== "true") return
await execFileAsync(
"pwsh",
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
{ cwd: rootDir },
)
}
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,6 +64,9 @@ const getBase = (): Configuration => ({
},
win: {
icon: `resources/icons/icon.ico`,
signtoolOptions: {
sign: signWindows,
},
target: ["nsis"],
},
nsis: {
@@ -9,3 +9,6 @@ Here's the process I've been using to create icons:
The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS,
so app icons appear larger than expected.
For unpackaged Electron on macOS, `app.dock.setIcon()` should use a PNG. Keep `dock.png` in each channel folder synced with the
extracted `icon_128x128@2x.png` from that channel's `icon.icns` so the dev Dock icon matches the packaged app inset.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+2 -1
View File
@@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
@@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) {
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${source} to ${dest}`)
@@ -50,7 +50,8 @@ export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> =
export function setDockIcon() {
if (process.platform !== "darwin") return
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow(globals: Globals) {
+2 -1
View File
@@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
+3
View File
@@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA
await $`mkdir -p src-tauri/sidecars`
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
console.log(`Copied ${source} to ${dest}`)
}
@@ -12,6 +12,10 @@
"icons/beta/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/beta/icon.ico"
}
@@ -45,6 +45,10 @@
"entitlements": "./entitlements.plist"
},
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/dev/icon.ico",
"headerImage": "assets/nsis-header.bmp",
@@ -12,6 +12,10 @@
"icons/prod/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/prod/icon.ico"
}
+22 -22
View File
@@ -68,25 +68,25 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.82",
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.54",
"@ai-sdk/google-vertex": "3.0.106",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.21",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.80",
"@ai-sdk/google": "3.0.53",
"@ai-sdk/google-vertex": "4.0.95",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -100,7 +100,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1",
@@ -110,7 +110,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
@@ -121,7 +121,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.3",
"gitlab-ai-provider": "6.0.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+79 -5
View File
@@ -212,8 +212,82 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
Still open and likely worth migrating:
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Session``session/index.ts`
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
Other services not yet migrated:
- [ ] `SessionSummary``session/summary.ts`
- [ ] `SessionTodo``session/todo.ts`
- [ ] `SessionRevert``session/revert.ts`
- [ ] `Instruction``session/instruction.ts`
- [ ] `ShareNext``share/share-next.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Storage``storage/storage.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `plan.ts` — LOW: plan file operations
## Effect service adoption in already-migrated code
Some services are effectified but still use raw `Filesystem.*` or `Process.spawn` instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
- [ ] `file/index.ts` — 11 calls (the File service itself)
- [ ] `config/config.ts` — 7 calls
- [ ] `auth/index.ts` — 3 calls
- [ ] `skill/index.ts` — 3 calls
- [ ] `file/time.ts` — 1 call
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
- [ ] `format/index.ts` — 1 call
## Filesystem consolidation
`util/filesystem.ts` (raw fs wrapper) is used by **64 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) exists but only has **8 consumers**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `tool/bash.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `storage/storage.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
## Primitives & utilities
- [ ] `util/lock.ts` — reader-writer lock → Effect Semaphore/Permit
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
+11 -3
View File
@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
- Author package entrypoint is `@opencode-ai/plugin/tui`.
- Internal plugins load inside the CLI app the same way external TUI plugins do.
- Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
## TUI config
@@ -27,6 +29,7 @@ Example:
- `plugin` entries can be either a string spec or `[spec, options]`.
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
- Relative path specs are resolved relative to the config file that declared them.
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
@@ -46,7 +49,7 @@ Minimal module shape:
```tsx
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [
@@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
export default plugin
```
- Loader only reads the module default export object. Named exports are ignored.
- TUI shape is `default export { id?, tui }`.
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`.
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
@@ -137,6 +144,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
- There is no uninstall, list, or update CLI command for external plugins.
- Local file plugins are configured directly in `tui.json`.
@@ -1,4 +1,4 @@
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,5 +1,5 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
const id = "internal:sidebar-context"
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-files"
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp"
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
import { TodoItem } from "../../component/todo-item"
@@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -1,9 +1,9 @@
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createEffect, createMemo, createSignal } from "solid-js"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0)
@@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
</Show>
</box>
)}
onConfirm={(raw) => {
@@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
])
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
@@ -231,9 +231,7 @@ async function loadExternalPlugin(
const mod = await import(entry)
.then((raw) => {
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
return mod
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
})
.catch((error) => {
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
@@ -566,16 +564,13 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
}
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
const plugin = load.module.tui
if (!plugin) return []
const options = load.item ? Config.pluginOptions(load.item) : undefined
return [
{
id: load.id,
load,
meta,
plugin,
plugin: load.module.tui,
options,
enabled: true,
},
@@ -1,14 +1,17 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount, type JSX } from "solid-js"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
let textarea: TextareaRenderable
useKeyboard((evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
}
@@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
createEffect(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) {
textarea.blur()
return
}
textarea.focus()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
{props.description}
<textarea
onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</box>
</box>
)
@@ -32,15 +32,7 @@ export const WorktreeAdaptor: Adaptor = {
const config = Config.parse(info)
await Worktree.remove({ directory: config.directory })
},
async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
const { Server } = await import("../../server/server")
const config = Config.parse(info)
const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
headers.set("x-opencode-directory", config.directory)
const request = new Request(url, { ...init, headers })
return Server.Default().fetch(request)
async fetch(_info, _input: RequestInfo | URL, _init?: RequestInit) {
throw new Error("fetch not implemented")
},
}
@@ -1,64 +0,0 @@
import type { MiddlewareHandler } from "hono"
import { Flag } from "../flag/flag"
import { getAdaptor } from "./adaptors"
import { WorkspaceID } from "./schema"
import { Workspace } from "./workspace"
import { InstanceRoutes } from "../server/instance"
import { lazy } from "../util/lazy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
function local(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
const routes = lazy(() => InstanceRoutes())
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
return routes().fetch(c.req.raw, c.env)
}
const url = new URL(c.req.url)
const raw = url.searchParams.get("workspace")
if (!raw) {
return routes().fetch(c.req.raw, c.env)
}
if (local(c.req.method, url.pathname)) {
return routes().fetch(c.req.raw, c.env)
}
const workspaceID = WorkspaceID.make(raw)
const workspace = await Workspace.get(workspaceID)
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
const adaptor = await getAdaptor(workspace.type)
const headers = new Headers(c.req.raw.headers)
headers.delete("x-opencode-workspace")
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
method: c.req.method,
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
signal: c.req.raw.signal,
headers,
})
}
+40 -28
View File
@@ -1,4 +1,6 @@
import { Effect, Layer, ServiceMap } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path"
@@ -6,7 +8,6 @@ import { mergeDeep } from "remeda"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
@@ -36,6 +37,7 @@ export namespace Format {
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
@@ -98,38 +100,45 @@ export namespace Format {
return checks.filter((x) => x.enabled).map((x) => x.item)
}
async function formatFile(filepath: string) {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command })
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
const code = yield* spawner
.spawn(
ChildProcess.make(cmd[0]!, cmd.slice(1), {
cwd: Instance.directory,
env: item.environment,
extendEnv: true,
}),
)
.pipe(
Effect.flatMap((handle) => handle.exitCode),
Effect.scoped,
Effect.catch(() =>
Effect.sync(() => {
log.error("failed to format file", {
error: "spawn failed",
command: item.command,
...item.environment,
file: filepath,
})
return ChildProcessSpawner.ExitCode(1)
}),
),
)
if (code !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
}
}
})
}
log.info("init")
@@ -162,14 +171,17 @@ export namespace Format {
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
yield* Effect.promise(() => formatFile(filepath))
yield* formatFile(filepath)
})
return Service.of({ init, status, file })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
+14 -5
View File
@@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "./shared"
export namespace Plugin {
@@ -35,6 +38,8 @@ export namespace Plugin {
type Loaded = {
item: Config.PluginSpec
spec: string
target: string
source: PluginSource
mod: Record<string, unknown>
}
@@ -112,7 +117,8 @@ export namespace Plugin {
const resolved = await resolvePlugin(spec)
if (!resolved) return
if (pluginSource(spec) === "npm") {
const source = pluginSource(spec)
if (source === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
@@ -156,14 +162,17 @@ export namespace Plugin {
return {
item,
spec,
target,
source,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
if (plugin?.server) {
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
return
}
+35 -12
View File
@@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
@@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
return value
}
export function readV1Plugin(
mod: Record<string, unknown>,
spec: string,
kind: PluginKind,
mode: PluginMode = "strict",
) {
const value = mod.default
if (!isRecord(value)) {
if (mode === "detect") return
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
}
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") {
throw new TypeError(`Plugin ${spec} has invalid server export`)
}
if (tui !== undefined && typeof tui !== "function") {
throw new TypeError(`Plugin ${spec} has invalid tui export`)
}
if (server !== undefined && tui !== undefined) {
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
}
if (kind === "server" && server === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
}
if (kind === "tui" && tui === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
}
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
if (source === "file") {
if (id) return id
@@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
}
return pkg.json.name.trim()
}
export function getDefaultPlugin(mod: Record<string, unknown>) {
// A single default object keeps v1 detection explicit and avoids scanning exports.
const value = mod.default
if (!isRecord(value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") return
if (tui !== undefined && typeof tui !== "function") return
if (server === undefined && tui === undefined) return
return value
}
+3
View File
@@ -23,6 +23,9 @@ export namespace ProviderError {
/request entity too large/i, // HTTP 413
/context length is only \d+ tokens/i, // vLLM
/input length.*exceeds.*context length/i, // vLLM
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
/too large for model with \d+ maximum context length/i, // Mistral
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
]
function isOpenAiErrorRetryable(e: APICallError) {
+56 -17
View File
@@ -9,6 +9,7 @@ import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
@@ -28,7 +29,7 @@ import { createVertex } from "@ai-sdk/google-vertex"
import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
import { createXai } from "@ai-sdk/xai"
import { createMistral } from "@ai-sdk/mistral"
@@ -109,7 +110,11 @@ export namespace Provider {
})
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => BundledSDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
"@ai-sdk/azure": createAzure,
@@ -130,7 +135,6 @@ export namespace Provider {
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
"gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -591,7 +595,12 @@ export namespace Provider {
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
project: result.project
? {
id: result.project.id,
path: result.project.pathWithNamespace,
}
: null,
})
return {}
}
@@ -619,8 +628,20 @@ export namespace Provider {
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: true,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
release_date: "",
@@ -930,17 +951,17 @@ export namespace Provider {
}
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const languages = new Map<string, LanguageModelV2>()
const languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const varsLoaders: {
[providerID: string]: CustomVarsLoader
} = {}
const sdk = new Map<string, BundledSDK>()
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1232,7 +1253,13 @@ export namespace Provider {
...model.headers,
}
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
npm: model.api.npm,
options,
}),
)
const existing = s.sdk.get(key)
if (existing) return existing
@@ -1285,7 +1312,10 @@ export namespace Provider {
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
if (bundledFn) {
log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm })
log.info("using bundled provider", {
providerID: model.providerID,
pkg: model.api.npm,
})
const loaded = bundledFn({
name: model.providerID,
...options,
@@ -1325,7 +1355,10 @@ export namespace Provider {
const provider = s.providers[providerID]
if (!provider) {
const availableProviders = Object.keys(s.providers)
const matches = fuzzysort.go(providerID, availableProviders, { limit: 3, threshold: -10000 })
const matches = fuzzysort.go(providerID, availableProviders, {
limit: 3,
threshold: -10000,
})
const suggestions = matches.map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
@@ -1333,14 +1366,17 @@ export namespace Provider {
const info = provider.models[modelID]
if (!info) {
const availableModels = Object.keys(provider.models)
const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 })
const matches = fuzzysort.go(modelID, availableModels, {
limit: 3,
threshold: -10000,
})
const suggestions = matches.map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
return info
}
export async function getLanguage(model: Model): Promise<LanguageModelV2> {
export async function getLanguage(model: Model): Promise<LanguageModelV3> {
const s = await state()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
@@ -1350,7 +1386,10 @@ export namespace Provider {
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options })
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
...model.options,
})
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
@@ -1457,9 +1496,9 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model)
const providers = await list()
const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>(
path.join(Global.Path.state, "model.json"),
)
const recent = (await Filesystem.readJson<{
recent?: { providerID: ProviderID; modelID: ModelID }[]
}>(path.join(Global.Path.state, "model.json"))
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
.catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[]
for (const entry of recent) {
@@ -1,16 +1,16 @@
import {
type LanguageModelV2Prompt,
type SharedV2ProviderMetadata,
type LanguageModelV3Prompt,
type SharedV3ProviderOptions,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import type { OpenAICompatibleChatPrompt } from "./openai-compatible-api-types"
import { convertToBase64 } from "@ai-sdk/provider-utils"
function getOpenAIMetadata(message: { providerOptions?: SharedV2ProviderMetadata }) {
function getOpenAIMetadata(message: { providerOptions?: SharedV3ProviderOptions }) {
return message?.providerOptions?.copilot ?? {}
}
export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Prompt): OpenAICompatibleChatPrompt {
export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV3Prompt): OpenAICompatibleChatPrompt {
const messages: OpenAICompatibleChatPrompt = []
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message })
@@ -127,6 +127,9 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
case "tool": {
for (const toolResponse of content) {
if (toolResponse.type === "tool-approval-response") {
continue
}
const output = toolResponse.output
let contentValue: string
@@ -135,6 +138,9 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
case "error-text":
contentValue = output.value
break
case "execution-denied":
contentValue = output.reason ?? "Tool execution denied."
break
case "content":
case "json":
case "error-json":
@@ -1,6 +1,8 @@
import type { LanguageModelV2FinishReason } from "@ai-sdk/provider"
import type { LanguageModelV3FinishReason } from "@ai-sdk/provider"
export function mapOpenAICompatibleFinishReason(finishReason: string | null | undefined): LanguageModelV2FinishReason {
export function mapOpenAICompatibleFinishReason(
finishReason: string | null | undefined,
): LanguageModelV3FinishReason["unified"] {
switch (finishReason) {
case "stop":
return "stop"
@@ -12,6 +14,6 @@ export function mapOpenAICompatibleFinishReason(finishReason: string | null | un
case "tool_calls":
return "tool-calls"
default:
return "unknown"
return "other"
}
}
@@ -1,12 +1,12 @@
import {
APICallError,
InvalidResponseDataError,
type LanguageModelV2,
type LanguageModelV2CallWarning,
type LanguageModelV2Content,
type LanguageModelV2FinishReason,
type LanguageModelV2StreamPart,
type SharedV2ProviderMetadata,
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3Content,
type LanguageModelV3StreamPart,
type SharedV3ProviderMetadata,
type SharedV3Warning,
} from "@ai-sdk/provider"
import {
combineHeaders,
@@ -47,11 +47,11 @@ export type OpenAICompatibleChatConfig = {
/**
* The supported URLs for the model.
*/
supportedUrls?: () => LanguageModelV2["supportedUrls"]
supportedUrls?: () => LanguageModelV3["supportedUrls"]
}
export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
readonly specificationVersion = "v2"
export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
readonly specificationVersion = "v3"
readonly supportsStructuredOutputs: boolean
@@ -98,8 +98,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
seed,
toolChoice,
tools,
}: Parameters<LanguageModelV2["doGenerate"]>[0]) {
const warnings: LanguageModelV2CallWarning[] = []
}: LanguageModelV3CallOptions) {
const warnings: SharedV3Warning[] = []
// Parse provider options
const compatibleOptions = Object.assign(
@@ -116,13 +116,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
)
if (topK != null) {
warnings.push({ type: "unsupported-setting", setting: "topK" })
warnings.push({ type: "unsupported", feature: "topK" })
}
if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) {
warnings.push({
type: "unsupported-setting",
setting: "responseFormat",
type: "unsupported",
feature: "responseFormat",
details: "JSON response format schema is only supported with structuredOutputs",
})
}
@@ -189,9 +189,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
}
}
async doGenerate(
options: Parameters<LanguageModelV2["doGenerate"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> {
async doGenerate(options: LanguageModelV3CallOptions) {
const { args, warnings } = await this.getArgs({ ...options })
const body = JSON.stringify(args)
@@ -214,7 +212,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
})
const choice = responseBody.choices[0]
const content: Array<LanguageModelV2Content> = []
const content: Array<LanguageModelV3Content> = []
// text content:
const text = choice.message.content
@@ -257,7 +255,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
}
// provider metadata:
const providerMetadata: SharedV2ProviderMetadata = {
const providerMetadata: SharedV3ProviderMetadata = {
[this.providerOptionsName]: {},
...(await this.config.metadataExtractor?.extractMetadata?.({
parsedBody: rawResponse,
@@ -275,13 +273,23 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
return {
content,
finishReason: mapOpenAICompatibleFinishReason(choice.finish_reason),
finishReason: {
unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
raw: choice.finish_reason ?? undefined,
},
usage: {
inputTokens: responseBody.usage?.prompt_tokens ?? undefined,
outputTokens: responseBody.usage?.completion_tokens ?? undefined,
totalTokens: responseBody.usage?.total_tokens ?? undefined,
reasoningTokens: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
cachedInputTokens: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined,
inputTokens: {
total: responseBody.usage?.prompt_tokens ?? undefined,
noCache: undefined,
cacheRead: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined,
cacheWrite: undefined,
},
outputTokens: {
total: responseBody.usage?.completion_tokens ?? undefined,
text: undefined,
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
},
raw: responseBody.usage ?? undefined,
},
providerMetadata,
request: { body },
@@ -294,9 +302,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
}
}
async doStream(
options: Parameters<LanguageModelV2["doStream"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> {
async doStream(options: LanguageModelV3CallOptions) {
const { args, warnings } = await this.getArgs({ ...options })
const body = {
@@ -332,7 +338,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
hasFinished: boolean
}> = []
let finishReason: LanguageModelV2FinishReason = "unknown"
let finishReason: {
unified: ReturnType<typeof mapOpenAICompatibleFinishReason>
raw: string | undefined
} = {
unified: "other",
raw: undefined,
}
const usage: {
completionTokens: number | undefined
completionTokensDetails: {
@@ -366,7 +378,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
return {
stream: response.pipeThrough(
new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV2StreamPart>({
new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV3StreamPart>({
start(controller) {
controller.enqueue({ type: "stream-start", warnings })
},
@@ -380,7 +392,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
// handle failed chunk parsing / validation:
if (!chunk.success) {
finishReason = "error"
finishReason = {
unified: "error",
raw: undefined,
}
controller.enqueue({ type: "error", error: chunk.error })
return
}
@@ -390,7 +405,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
// handle error chunks:
if ("error" in value) {
finishReason = "error"
finishReason = {
unified: "error",
raw: undefined,
}
controller.enqueue({ type: "error", error: value.error.message })
return
}
@@ -435,7 +453,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
const choice = value.choices[0]
if (choice?.finish_reason != null) {
finishReason = mapOpenAICompatibleFinishReason(choice.finish_reason)
finishReason = {
unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
raw: choice.finish_reason ?? undefined,
}
}
if (choice?.delta == null) {
@@ -652,7 +673,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
})
}
const providerMetadata: SharedV2ProviderMetadata = {
const providerMetadata: SharedV3ProviderMetadata = {
[providerOptionsName]: {},
// Include reasoning_opaque for Copilot multi-turn reasoning
...(reasoningOpaque ? { copilot: { reasoningOpaque } } : {}),
@@ -671,11 +692,25 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
type: "finish",
finishReason,
usage: {
inputTokens: usage.promptTokens ?? undefined,
outputTokens: usage.completionTokens ?? undefined,
totalTokens: usage.totalTokens ?? undefined,
reasoningTokens: usage.completionTokensDetails.reasoningTokens ?? undefined,
cachedInputTokens: usage.promptTokensDetails.cachedTokens ?? undefined,
inputTokens: {
total: usage.promptTokens,
noCache:
usage.promptTokens != undefined && usage.promptTokensDetails.cachedTokens != undefined
? usage.promptTokens - usage.promptTokensDetails.cachedTokens
: undefined,
cacheRead: usage.promptTokensDetails.cachedTokens,
cacheWrite: undefined,
},
outputTokens: {
total: usage.completionTokens,
text: undefined,
reasoning: usage.completionTokensDetails.reasoningTokens,
},
raw: {
prompt_tokens: usage.promptTokens ?? null,
completion_tokens: usage.completionTokens ?? null,
total_tokens: usage.totalTokens ?? null,
},
},
providerMetadata,
})
@@ -1,4 +1,4 @@
import type { SharedV2ProviderMetadata } from "@ai-sdk/provider"
import type { SharedV3ProviderMetadata } from "@ai-sdk/provider"
/**
Extracts provider-specific metadata from API responses.
@@ -14,7 +14,7 @@ export type MetadataExtractor = {
* @returns Provider-specific metadata or undefined if no metadata is available.
* The metadata should be under a key indicating the provider id.
*/
extractMetadata: ({ parsedBody }: { parsedBody: unknown }) => Promise<SharedV2ProviderMetadata | undefined>
extractMetadata: ({ parsedBody }: { parsedBody: unknown }) => Promise<SharedV3ProviderMetadata | undefined>
/**
* Creates an extractor for handling streaming responses. The returned object provides
@@ -39,6 +39,6 @@ export type MetadataExtractor = {
* @returns Provider-specific metadata or undefined if no metadata is available.
* The metadata should be under a key indicating the provider id.
*/
buildMetadata(): SharedV2ProviderMetadata | undefined
buildMetadata(): SharedV3ProviderMetadata | undefined
}
}
@@ -1,15 +1,11 @@
import {
type LanguageModelV2CallOptions,
type LanguageModelV2CallWarning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
export function prepareTools({
tools,
toolChoice,
}: {
tools: LanguageModelV2CallOptions["tools"]
toolChoice?: LanguageModelV2CallOptions["toolChoice"]
tools: LanguageModelV3CallOptions["tools"]
toolChoice?: LanguageModelV3CallOptions["toolChoice"]
}): {
tools:
| undefined
@@ -22,12 +18,12 @@ export function prepareTools({
}
}>
toolChoice: { type: "function"; function: { name: string } } | "auto" | "none" | "required" | undefined
toolWarnings: LanguageModelV2CallWarning[]
toolWarnings: SharedV3Warning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
tools = tools?.length ? tools : undefined
const toolWarnings: LanguageModelV2CallWarning[] = []
const toolWarnings: SharedV3Warning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
@@ -43,8 +39,8 @@ export function prepareTools({
}> = []
for (const tool of tools) {
if (tool.type === "provider-defined") {
toolWarnings.push({ type: "unsupported-tool", tool })
if (tool.type === "provider") {
toolWarnings.push({ type: "unsupported", feature: `tool type: ${tool.type}` })
} else {
openaiCompatTools.push({
type: "function",
@@ -1,4 +1,4 @@
import type { LanguageModelV2 } from "@ai-sdk/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils"
import { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model"
import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model"
@@ -36,10 +36,10 @@ export interface OpenaiCompatibleProviderSettings {
}
export interface OpenaiCompatibleProvider {
(modelId: OpenaiCompatibleModelId): LanguageModelV2
chat(modelId: OpenaiCompatibleModelId): LanguageModelV2
responses(modelId: OpenaiCompatibleModelId): LanguageModelV2
languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV2
(modelId: OpenaiCompatibleModelId): LanguageModelV3
chat(modelId: OpenaiCompatibleModelId): LanguageModelV3
responses(modelId: OpenaiCompatibleModelId): LanguageModelV3
languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV3
// embeddingModel(modelId: any): EmbeddingModelV2
@@ -1,7 +1,7 @@
import {
type LanguageModelV2CallWarning,
type LanguageModelV2Prompt,
type LanguageModelV2ToolCallPart,
type LanguageModelV3Prompt,
type LanguageModelV3ToolCallPart,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
@@ -25,17 +25,18 @@ export async function convertToOpenAIResponsesInput({
store,
hasLocalShellTool = false,
}: {
prompt: LanguageModelV2Prompt
prompt: LanguageModelV3Prompt
systemMessageMode: "system" | "developer" | "remove"
fileIdPrefixes?: readonly string[]
store: boolean
hasLocalShellTool?: boolean
}): Promise<{
input: OpenAIResponsesInput
warnings: Array<LanguageModelV2CallWarning>
warnings: Array<SharedV3Warning>
}> {
const input: OpenAIResponsesInput = []
const warnings: Array<LanguageModelV2CallWarning> = []
const warnings: Array<SharedV3Warning> = []
const processedApprovalIds = new Set<string>()
for (const { role, content } of prompt) {
switch (role) {
@@ -118,7 +119,7 @@ export async function convertToOpenAIResponsesInput({
case "assistant": {
const reasoningMessages: Record<string, OpenAIResponsesReasoning> = {}
const toolCallParts: Record<string, LanguageModelV2ToolCallPart> = {}
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}
for (const part of content) {
switch (part.type) {
@@ -251,8 +252,36 @@ export async function convertToOpenAIResponsesInput({
case "tool": {
for (const part of content) {
if (part.type === "tool-approval-response") {
if (processedApprovalIds.has(part.approvalId)) {
continue
}
processedApprovalIds.add(part.approvalId)
if (store) {
input.push({
type: "item_reference",
id: part.approvalId,
})
}
input.push({
type: "mcp_approval_response",
approval_request_id: part.approvalId,
approve: part.approved,
})
continue
}
const output = part.output
if (output.type === "execution-denied") {
const approvalId = (output.providerOptions?.openai as { approvalId?: string } | undefined)?.approvalId
if (approvalId) {
continue
}
}
if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") {
input.push({
type: "local_shell_call_output",
@@ -268,6 +297,9 @@ export async function convertToOpenAIResponsesInput({
case "error-text":
contentValue = output.value
break
case "execution-denied":
contentValue = output.reason ?? "Tool execution denied."
break
case "content":
case "json":
case "error-json":
@@ -1,4 +1,4 @@
import type { LanguageModelV2FinishReason } from "@ai-sdk/provider"
import type { LanguageModelV3FinishReason } from "@ai-sdk/provider"
export function mapOpenAIResponseFinishReason({
finishReason,
@@ -7,7 +7,7 @@ export function mapOpenAIResponseFinishReason({
finishReason: string | null | undefined
// flag that checks if there have been client-side tool calls (not executed by openai)
hasFunctionCall: boolean
}): LanguageModelV2FinishReason {
}): LanguageModelV3FinishReason["unified"] {
switch (finishReason) {
case undefined:
case null:
@@ -17,6 +17,6 @@ export function mapOpenAIResponseFinishReason({
case "content_filter":
return "content-filter"
default:
return hasFunctionCall ? "tool-calls" : "unknown"
return hasFunctionCall ? "tool-calls" : "other"
}
}
@@ -13,6 +13,7 @@ export type OpenAIResponsesInputItem =
| OpenAIResponsesLocalShellCallOutput
| OpenAIResponsesReasoning
| OpenAIResponsesItemReference
| OpenAIResponsesMcpApprovalResponse
export type OpenAIResponsesIncludeValue =
| "web_search_call.action.sources"
@@ -93,6 +94,12 @@ export type OpenAIResponsesItemReference = {
id: string
}
export type OpenAIResponsesMcpApprovalResponse = {
type: "mcp_approval_response"
approval_request_id: string
approve: boolean
}
/**
* A filter used to compare a specified attribute key to a given value using a defined comparison operation.
*/
@@ -1,13 +1,13 @@
import {
APICallError,
type LanguageModelV2,
type LanguageModelV2CallWarning,
type LanguageModelV2Content,
type LanguageModelV2FinishReason,
type LanguageModelV2ProviderDefinedTool,
type LanguageModelV2StreamPart,
type LanguageModelV2Usage,
type SharedV2ProviderMetadata,
type JSONValue,
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3Content,
type LanguageModelV3ProviderTool,
type LanguageModelV3StreamPart,
type SharedV3ProviderMetadata,
type SharedV3Warning,
} from "@ai-sdk/provider"
import {
combineHeaders,
@@ -128,8 +128,8 @@ const LOGPROBS_SCHEMA = z.array(
}),
)
export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
readonly specificationVersion = "v2"
export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
readonly specificationVersion = "v3"
readonly modelId: OpenAIResponsesModelId
@@ -163,34 +163,34 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
tools,
toolChoice,
responseFormat,
}: Parameters<LanguageModelV2["doGenerate"]>[0]) {
const warnings: LanguageModelV2CallWarning[] = []
}: LanguageModelV3CallOptions) {
const warnings: SharedV3Warning[] = []
const modelConfig = getResponsesModelConfig(this.modelId)
if (topK != null) {
warnings.push({ type: "unsupported-setting", setting: "topK" })
warnings.push({ type: "unsupported", feature: "topK" })
}
if (seed != null) {
warnings.push({ type: "unsupported-setting", setting: "seed" })
warnings.push({ type: "unsupported", feature: "seed" })
}
if (presencePenalty != null) {
warnings.push({
type: "unsupported-setting",
setting: "presencePenalty",
type: "unsupported",
feature: "presencePenalty",
})
}
if (frequencyPenalty != null) {
warnings.push({
type: "unsupported-setting",
setting: "frequencyPenalty",
type: "unsupported",
feature: "frequencyPenalty",
})
}
if (stopSequences != null) {
warnings.push({ type: "unsupported-setting", setting: "stopSequences" })
warnings.push({ type: "unsupported", feature: "stopSequences" })
}
const openaiOptions = await parseProviderOptions({
@@ -218,7 +218,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
}
function hasOpenAITool(id: string) {
return tools?.find((tool) => tool.type === "provider-defined" && tool.id === id) != null
return tools?.find((tool) => tool.type === "provider" && tool.id === id) != null
}
// when logprobs are requested, automatically include them:
@@ -237,9 +237,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
const webSearchToolName = (
tools?.find(
(tool) =>
tool.type === "provider-defined" &&
(tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
) as LanguageModelV2ProviderDefinedTool | undefined
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
) as LanguageModelV3ProviderTool | undefined
)?.name
if (webSearchToolName) {
@@ -315,8 +314,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
if (baseArgs.temperature != null) {
baseArgs.temperature = undefined
warnings.push({
type: "unsupported-setting",
setting: "temperature",
type: "unsupported",
feature: "temperature",
details: "temperature is not supported for reasoning models",
})
}
@@ -324,24 +323,24 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
if (baseArgs.top_p != null) {
baseArgs.top_p = undefined
warnings.push({
type: "unsupported-setting",
setting: "topP",
type: "unsupported",
feature: "topP",
details: "topP is not supported for reasoning models",
})
}
} else {
if (openaiOptions?.reasoningEffort != null) {
warnings.push({
type: "unsupported-setting",
setting: "reasoningEffort",
type: "unsupported",
feature: "reasoningEffort",
details: "reasoningEffort is not supported for non-reasoning models",
})
}
if (openaiOptions?.reasoningSummary != null) {
warnings.push({
type: "unsupported-setting",
setting: "reasoningSummary",
type: "unsupported",
feature: "reasoningSummary",
details: "reasoningSummary is not supported for non-reasoning models",
})
}
@@ -350,8 +349,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// Validate flex processing support
if (openaiOptions?.serviceTier === "flex" && !modelConfig.supportsFlexProcessing) {
warnings.push({
type: "unsupported-setting",
setting: "serviceTier",
type: "unsupported",
feature: "serviceTier",
details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
})
// Remove from args if not supported
@@ -361,8 +360,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// Validate priority processing support
if (openaiOptions?.serviceTier === "priority" && !modelConfig.supportsPriorityProcessing) {
warnings.push({
type: "unsupported-setting",
setting: "serviceTier",
type: "unsupported",
feature: "serviceTier",
details:
"priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported",
})
@@ -391,9 +390,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
}
}
async doGenerate(
options: Parameters<LanguageModelV2["doGenerate"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> {
async doGenerate(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const url = this.config.url({
path: "/responses",
@@ -508,7 +505,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
})
}
const content: Array<LanguageModelV2Content> = []
const content: Array<LanguageModelV3Content> = []
const logprobs: Array<z.infer<typeof LOGPROBS_SCHEMA>> = []
// flag that checks if there have been client-side tool calls (not executed by openai)
@@ -554,7 +551,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
result: {
result: part.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
providerExecuted: true,
})
break
@@ -648,7 +644,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
result: { status: part.status },
providerExecuted: true,
})
break
@@ -671,7 +666,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
type: "computer_use_tool_result",
status: part.status || "completed",
},
providerExecuted: true,
})
break
}
@@ -693,14 +687,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
queries: part.queries,
results:
part.results?.map((result) => ({
attributes: result.attributes,
attributes: result.attributes as Record<string, JSONValue>,
fileId: result.file_id,
filename: result.filename,
score: result.score,
text: result.text,
})) ?? null,
} satisfies z.infer<typeof fileSearchOutputSchema>,
providerExecuted: true,
})
break
}
@@ -724,14 +717,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
result: {
outputs: part.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
providerExecuted: true,
})
break
}
}
}
const providerMetadata: SharedV2ProviderMetadata = {
const providerMetadata: SharedV3ProviderMetadata = {
openai: { responseId: response.id },
}
@@ -745,16 +737,29 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
return {
content,
finishReason: mapOpenAIResponseFinishReason({
finishReason: response.incomplete_details?.reason,
hasFunctionCall,
}),
finishReason: {
unified: mapOpenAIResponseFinishReason({
finishReason: response.incomplete_details?.reason,
hasFunctionCall,
}),
raw: response.incomplete_details?.reason,
},
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
totalTokens: response.usage.input_tokens + response.usage.output_tokens,
reasoningTokens: response.usage.output_tokens_details?.reasoning_tokens ?? undefined,
cachedInputTokens: response.usage.input_tokens_details?.cached_tokens ?? undefined,
inputTokens: {
total: response.usage.input_tokens,
noCache:
response.usage.input_tokens_details?.cached_tokens != null
? response.usage.input_tokens - response.usage.input_tokens_details.cached_tokens
: undefined,
cacheRead: response.usage.input_tokens_details?.cached_tokens ?? undefined,
cacheWrite: undefined,
},
outputTokens: {
total: response.usage.output_tokens,
text: undefined,
reasoning: response.usage.output_tokens_details?.reasoning_tokens ?? undefined,
},
raw: response.usage,
},
request: { body },
response: {
@@ -769,9 +774,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
}
}
async doStream(
options: Parameters<LanguageModelV2["doStream"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> {
async doStream(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { responseHeaders, value: response } = await postJsonToApi({
@@ -792,11 +795,25 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
const self = this
let finishReason: LanguageModelV2FinishReason = "unknown"
const usage: LanguageModelV2Usage = {
let finishReason: {
unified: ReturnType<typeof mapOpenAIResponseFinishReason>
raw: string | undefined
} = {
unified: "other",
raw: undefined,
}
const usage: {
inputTokens: number | undefined
outputTokens: number | undefined
totalTokens: number | undefined
reasoningTokens: number | undefined
cachedInputTokens: number | undefined
} = {
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
reasoningTokens: undefined,
cachedInputTokens: undefined,
}
const logprobs: Array<z.infer<typeof LOGPROBS_SCHEMA>> = []
let responseId: string | null = null
@@ -837,7 +854,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
return {
stream: response.pipeThrough(
new TransformStream<ParseResult<z.infer<typeof openaiResponsesChunkSchema>>, LanguageModelV2StreamPart>({
new TransformStream<ParseResult<z.infer<typeof openaiResponsesChunkSchema>>, LanguageModelV3StreamPart>({
start(controller) {
controller.enqueue({ type: "stream-start", warnings })
},
@@ -849,7 +866,10 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// handle failed chunk parsing / validation:
if (!chunk.success) {
finishReason = "error"
finishReason = {
unified: "error",
raw: undefined,
}
controller.enqueue({ type: "error", error: chunk.error })
return
}
@@ -999,7 +1019,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
toolCallId: value.item.id,
toolName: "web_search",
result: { status: value.item.status },
providerExecuted: true,
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = undefined
@@ -1025,7 +1044,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
type: "computer_use_tool_result",
status: value.item.status || "completed",
},
providerExecuted: true,
})
} else if (value.item.type === "file_search_call") {
ongoingToolCalls[value.output_index] = undefined
@@ -1038,14 +1056,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
queries: value.item.queries,
results:
value.item.results?.map((result) => ({
attributes: result.attributes,
attributes: result.attributes as Record<string, JSONValue>,
fileId: result.file_id,
filename: result.filename,
score: result.score,
text: result.text,
})) ?? null,
} satisfies z.infer<typeof fileSearchOutputSchema>,
providerExecuted: true,
})
} else if (value.item.type === "code_interpreter_call") {
ongoingToolCalls[value.output_index] = undefined
@@ -1057,7 +1074,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
result: {
outputs: value.item.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
providerExecuted: true,
})
} else if (value.item.type === "image_generation_call") {
controller.enqueue({
@@ -1067,7 +1083,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
result: {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
providerExecuted: true,
})
} else if (value.item.type === "local_shell_call") {
ongoingToolCalls[value.output_index] = undefined
@@ -1137,7 +1152,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
result: {
result: value.partial_image_b64,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
providerExecuted: true,
})
} else if (isResponseCodeInterpreterCallCodeDeltaChunk(value)) {
const toolCall = ongoingToolCalls[value.output_index]
@@ -1244,10 +1258,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
})
}
} else if (isResponseFinishedChunk(value)) {
finishReason = mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,
hasFunctionCall,
})
finishReason = {
unified: mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,
hasFunctionCall,
}),
raw: value.response.incomplete_details?.reason ?? undefined,
}
usage.inputTokens = value.response.usage.input_tokens
usage.outputTokens = value.response.usage.output_tokens
usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens
@@ -1287,7 +1304,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
currentTextId = null
}
const providerMetadata: SharedV2ProviderMetadata = {
const providerMetadata: SharedV3ProviderMetadata = {
openai: {
responseId,
},
@@ -1304,7 +1321,27 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
controller.enqueue({
type: "finish",
finishReason,
usage,
usage: {
inputTokens: {
total: usage.inputTokens,
noCache:
usage.inputTokens != null && usage.cachedInputTokens != null
? usage.inputTokens - usage.cachedInputTokens
: undefined,
cacheRead: usage.cachedInputTokens,
cacheWrite: undefined,
},
outputTokens: {
total: usage.outputTokens,
text: undefined,
reasoning: usage.reasoningTokens,
},
raw: {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
total_tokens: usage.totalTokens,
},
},
providerMetadata,
})
},
@@ -1,8 +1,4 @@
import {
type LanguageModelV2CallOptions,
type LanguageModelV2CallWarning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import { codeInterpreterArgsSchema } from "./tool/code-interpreter"
import { fileSearchArgsSchema } from "./tool/file-search"
import { webSearchArgsSchema } from "./tool/web-search"
@@ -15,8 +11,8 @@ export function prepareResponsesTools({
toolChoice,
strictJsonSchema,
}: {
tools: LanguageModelV2CallOptions["tools"]
toolChoice?: LanguageModelV2CallOptions["toolChoice"]
tools: LanguageModelV3CallOptions["tools"]
toolChoice?: LanguageModelV3CallOptions["toolChoice"]
strictJsonSchema: boolean
}): {
tools?: Array<OpenAIResponsesTool>
@@ -30,12 +26,12 @@ export function prepareResponsesTools({
| { type: "function"; name: string }
| { type: "code_interpreter" }
| { type: "image_generation" }
toolWarnings: LanguageModelV2CallWarning[]
toolWarnings: SharedV3Warning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
tools = tools?.length ? tools : undefined
const toolWarnings: LanguageModelV2CallWarning[] = []
const toolWarnings: SharedV3Warning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
@@ -54,7 +50,7 @@ export function prepareResponsesTools({
strict: strictJsonSchema,
})
break
case "provider-defined": {
case "provider": {
switch (tool.id) {
case "openai.file_search": {
const args = fileSearchArgsSchema.parse(tool.args)
@@ -138,7 +134,7 @@ export function prepareResponsesTools({
break
}
default:
toolWarnings.push({ type: "unsupported-tool", tool })
toolWarnings.push({ type: "unsupported", feature: "tool type" })
break
}
}
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const codeInterpreterInputSchema = z.object({
@@ -37,7 +37,7 @@ type CodeInterpreterArgs = {
container?: string | { fileIds?: string[] }
}
export const codeInterpreterToolFactory = createProviderDefinedToolFactoryWithOutputSchema<
export const codeInterpreterToolFactory = createProviderToolFactoryWithOutputSchema<
{
/**
* The code to run, or null if not available.
@@ -76,7 +76,6 @@ export const codeInterpreterToolFactory = createProviderDefinedToolFactoryWithOu
CodeInterpreterArgs
>({
id: "openai.code_interpreter",
name: "code_interpreter",
inputSchema: codeInterpreterInputSchema,
outputSchema: codeInterpreterOutputSchema,
})
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import type {
OpenAIResponsesFileSearchToolComparisonFilter,
OpenAIResponsesFileSearchToolCompoundFilter,
@@ -43,7 +43,7 @@ export const fileSearchOutputSchema = z.object({
.nullable(),
})
export const fileSearch = createProviderDefinedToolFactoryWithOutputSchema<
export const fileSearch = createProviderToolFactoryWithOutputSchema<
{},
{
/**
@@ -122,7 +122,6 @@ export const fileSearch = createProviderDefinedToolFactoryWithOutputSchema<
}
>({
id: "openai.file_search",
name: "file_search",
inputSchema: z.object({}),
outputSchema: fileSearchOutputSchema,
})
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const imageGenerationArgsSchema = z
@@ -92,7 +92,7 @@ type ImageGenerationArgs = {
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
}
const imageGenerationToolFactory = createProviderDefinedToolFactoryWithOutputSchema<
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
{},
{
/**
@@ -103,7 +103,6 @@ const imageGenerationToolFactory = createProviderDefinedToolFactoryWithOutputSch
ImageGenerationArgs
>({
id: "openai.image_generation",
name: "image_generation",
inputSchema: z.object({}),
outputSchema: imageGenerationOutputSchema,
})
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const localShellInputSchema = z.object({
@@ -16,7 +16,7 @@ export const localShellOutputSchema = z.object({
output: z.string(),
})
export const localShell = createProviderDefinedToolFactoryWithOutputSchema<
export const localShell = createProviderToolFactoryWithOutputSchema<
{
/**
* Execute a shell command on the server.
@@ -59,7 +59,6 @@ export const localShell = createProviderDefinedToolFactoryWithOutputSchema<
{}
>({
id: "openai.local_shell",
name: "local_shell",
inputSchema: localShellInputSchema,
outputSchema: localShellOutputSchema,
})
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils"
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
// Args validation schema
@@ -40,7 +40,7 @@ export const webSearchPreviewArgsSchema = z.object({
.optional(),
})
export const webSearchPreview = createProviderDefinedToolFactory<
export const webSearchPreview = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
@@ -81,7 +81,6 @@ export const webSearchPreview = createProviderDefinedToolFactory<
}
>({
id: "openai.web_search_preview",
name: "web_search_preview",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
@@ -1,4 +1,4 @@
import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils"
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const webSearchArgsSchema = z.object({
@@ -21,7 +21,7 @@ export const webSearchArgsSchema = z.object({
.optional(),
})
export const webSearchToolFactory = createProviderDefinedToolFactory<
export const webSearchToolFactory = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
@@ -74,7 +74,6 @@ export const webSearchToolFactory = createProviderDefinedToolFactory<
}
>({
id: "openai.web_search",
name: "web_search",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
+53 -25
View File
@@ -25,8 +25,9 @@ export namespace ProviderTransform {
switch (npm) {
case "@ai-sdk/github-copilot":
return "copilot"
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return "azure"
case "@ai-sdk/openai":
return "openai"
case "@ai-sdk/amazon-bedrock":
return "bedrock"
@@ -34,6 +35,7 @@ export namespace ProviderTransform {
case "@ai-sdk/google-vertex/anthropic":
return "anthropic"
case "@ai-sdk/google-vertex":
return "vertex"
case "@ai-sdk/google":
return "google"
case "@ai-sdk/gateway":
@@ -72,17 +74,29 @@ export namespace ProviderTransform {
}
if (model.api.id.includes("claude")) {
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {
return {
...part,
toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),
if (msg.role === "assistant" && Array.isArray(msg.content)) {
return {
...msg,
content: msg.content.map((part) => {
if (part.type === "tool-call" || part.type === "tool-result") {
return { ...part, toolCallId: scrub(part.toolCallId) }
}
}
return part
})
return part
}),
}
}
if (msg.role === "tool" && Array.isArray(msg.content)) {
return {
...msg,
content: msg.content.map((part) => {
if (part.type === "tool-result") {
return { ...part, toolCallId: scrub(part.toolCallId) }
}
return part
}),
}
}
return msg
})
@@ -92,29 +106,33 @@ export namespace ProviderTransform {
model.api.id.toLowerCase().includes("mistral") ||
model.api.id.toLocaleLowerCase().includes("devstral")
) {
const scrub = (id: string) => {
return id
.replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters
.substring(0, 9) // Take first 9 characters
.padEnd(9, "0") // Pad with zeros if less than 9 characters
}
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const nextMsg = msgs[i + 1]
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {
// Mistral requires alphanumeric tool call IDs with exactly 9 characters
const normalizedId = part.toolCallId
.replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters
.substring(0, 9) // Take first 9 characters
.padEnd(9, "0") // Pad with zeros if less than 9 characters
return {
...part,
toolCallId: normalizedId,
}
if (part.type === "tool-call" || part.type === "tool-result") {
return { ...part, toolCallId: scrub(part.toolCallId) }
}
return part
})
}
if (msg.role === "tool" && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
if (part.type === "tool-result") {
return { ...part, toolCallId: scrub(part.toolCallId) }
}
return part
})
}
result.push(msg)
// Fix message sequence: tool messages cannot be followed by user messages
@@ -202,7 +220,12 @@ export namespace ProviderTransform {
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
if (lastContent && typeof lastContent === "object") {
if (
lastContent &&
typeof lastContent === "object" &&
lastContent.type !== "tool-approval-request" &&
lastContent.type !== "tool-approval-response"
) {
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
continue
}
@@ -284,7 +307,12 @@ export namespace ProviderTransform {
return {
...msg,
providerOptions: remap(msg.providerOptions),
content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })),
content: msg.content.map((part) => {
if (part.type === "tool-approval-request" || part.type === "tool-approval-response") {
return { ...part }
}
return { ...part, providerOptions: remap(part.providerOptions) }
}),
} as typeof msg
})
}
-22
View File
@@ -14,7 +14,6 @@ import { Global } from "../global"
import { LSP } from "../lsp"
import { Command } from "../command"
import { Flag } from "../flag/flag"
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { ProjectRoutes } from "./routes/project"
@@ -26,7 +25,6 @@ import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { errorHandler } from "./middleware"
const log = Log.create({ service: "server" })
@@ -45,26 +43,6 @@ const csp = (hash = "") =>
export const InstanceRoutes = (app?: Hono) =>
(app ?? new Hono())
.onError(errorHandler(log))
.use(async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return next()
},
})
})
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
+99
View File
@@ -0,0 +1,99 @@
import type { MiddlewareHandler } from "hono"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
function local(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
const routes = lazy(() => InstanceRoutes())
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
const url = new URL(c.req.url)
const workspaceParam = url.searchParams.get("workspace")
// TODO: If session is being routed, force it to lookup the
// project/workspace
// If no workspace is provided we use the "project" workspace
if (!workspaceParam) {
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
})
}
const workspaceID = WorkspaceID.make(workspaceParam)
const workspace = await Workspace.get(workspaceID)
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
// Handle local workspaces directly so we can pass env to `fetch`,
// necessary for websocket upgrades
if (workspace.type === "worktree") {
return Instance.provide({
directory: workspace.directory!,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
})
}
// Remote workspaces
if (local(c.req.method, url.pathname)) {
// No instance provided because we are serving cached data; there
// is no instance to work with
return routes().fetch(c.req.raw, c.env)
}
const adaptor = await getAdaptor(workspace.type)
const headers = new Headers(c.req.raw.headers)
headers.delete("x-opencode-workspace")
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
method: c.req.method,
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
signal: c.req.raw.signal,
headers,
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ import z from "zod"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { WorkspaceRouterMiddleware } from "./router"
import { websocket } from "hono/bun"
import { errors } from "./error"
import { GlobalRoutes } from "./routes/global"
+339 -248
View File
@@ -16,6 +16,8 @@ import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -30,153 +32,196 @@ export namespace SessionCompaction {
}
const COMPACTION_BUFFER = 20_000
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input
? input.model.limit.input - reserved
: context - ProviderTransform.maxOutputTokens(input.model)
return count >= usable
}
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: SessionID }) {
const config = await Config.get()
if (config.compaction?.prune === false) return
log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
if (NotFoundError.isInstance(err)) return undefined
throw err
})
if (!msgs) return
let total = 0
let pruned = 0
const toPrune = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
}
export async function process(input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionCompaction") {}
let messages = input.messages
let replay: MessageV2.WithParts | undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = msg
messages = input.messages.slice(0, i)
break
export const layer: Layer.Layer<
Service,
never,
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
const cfg = yield* config.get()
if (cfg.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input
? input.model.limit.input - reserved
: context - ProviderTransform.maxOutputTokens(input.model)
return count >= usable
})
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = await Agent.get("compaction")
const model = agent.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
const msg = (await Session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
const processor = SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
abort: input.abort,
})
// Allow plugins to inject context or replace compaction prompt
const compacting = await Plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
})
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
let messages = input.messages
let replay: MessageV2.WithParts | undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = msg
messages = input.messages.slice(0, i)
break
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = yield* agents.get("compaction")
const model = yield* Effect.promise(() =>
agent.model
? Provider.getModel(agent.model.providerID, agent.model.modelID)
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
)
const msg = (yield* session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
const processor = yield* Effect.promise(() =>
SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
abort: input.abort,
}),
)
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
@@ -204,133 +249,179 @@ When constructing the summary, try to stick to this template:
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---`
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const result = await processor.process({
user: userMessage,
agent,
abort: input.abort,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...MessageV2.toModelMessages(msgs, model, { stripMedia: true }),
{
role: "user",
content: [
{
type: "text",
text: promptText,
},
],
},
],
model,
})
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
await Session.updateMessage(processor.message)
return "stop"
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info as MessageV2.User
const replayMsg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
variant: original.variant,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
await Session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
const result = yield* Effect.promise(() =>
processor.process({
user: userMessage,
agent,
abort: input.abort,
sessionID: input.sessionID,
})
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
}),
)
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
yield* session.updateMessage(processor.message)
return "stop"
}
} else {
const continueMsg = await Session.updateMessage({
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info as MessageV2.User
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
variant: original.variant,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
}
if (!replay) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
return result
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
await Session.updatePart({
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
}
}
if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
return "continue"
})
return Service.of({
isOverflow,
prune,
process: processCompaction,
create,
})
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export async function process(input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
abort: AbortSignal
auto: boolean
overflow?: boolean
}) {
return runPromise((svc) => svc.process(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
}),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
async (input) => {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: {
created: Date.now(),
},
})
await Session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
},
(input) => runPromise((svc) => svc.create(input)),
)
}
+502 -395
View File
@@ -33,6 +33,8 @@ import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -233,6 +235,473 @@ export namespace Session {
),
}
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const getUsage = (input: {
model: Provider.Model
usage: LanguageModelV2Usage
metadata?: ProviderMetadata
}) => {
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = safe(
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)
const total = iife(() => {
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
// don't compute from components
if (
input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
const tokens = {
total,
input: adjustedInputTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,
read: cacheReadInputTokens,
},
}
const costInfo =
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
? input.model.cost.experimentalOver200K
: input.model.cost
return {
cost: safe(
new Decimal(0)
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now:
// charge reasoning tokens at the same rate as output tokens
.add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))
.toNumber(),
),
tokens,
}
}
export class BusyError extends Error {
constructor(public readonly sessionID: string) {
super(`Session ${sessionID} is busy`)
}
}
export interface Interface {
readonly create: (input?: {
parentID?: SessionID
title?: string
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
readonly unshare: (id: SessionID) => Effect.Effect<void>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
readonly setRevert: (input: {
sessionID: SessionID
revert: Info["revert"]
summary: Info["summary"]
}) => Effect.Effect<void>
readonly clearRevert: (sessionID: SessionID) => Effect.Effect<void>
readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect<void>
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
readonly updateMessage: (msg: MessageV2.Info) => Effect.Effect<MessageV2.Info>
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
readonly removePart: (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
}) => Effect.Effect<PartID>
readonly updatePart: (part: MessageV2.Part) => Effect.Effect<MessageV2.Part>
readonly updatePartDelta: (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
field: string
delta: string
}) => Effect.Effect<void>
readonly initialize: (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
type Patch = z.infer<typeof Event.Updated.schema>["info"]
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const scope = yield* Scope.Scope
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
title?: string
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
permission?: Permission.Ruleset
}) {
const result: Info = {
id: SessionID.descending(input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
time: {
created: Date.now(),
updated: Date.now(),
},
}
log.info("created", result)
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
const cfg = yield* config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
yield* bus.publish(Event.Updated, {
sessionID: result.id,
info: result,
})
}
return result
})
const get = Effect.fn("Session.get")(function* (id: SessionID) {
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
return fromRow(row)
})
const share = Effect.fn("Session.share")(function* (id: SessionID) {
const cfg = yield* config.get()
if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
return ShareNext.create(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
return result
})
const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const project = Instance.project
const rows = yield* db((d) =>
d
.select()
.from(SessionTable)
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
.all(),
)
return rows.map(fromRow)
})
const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
}
yield* unshare(sessionID).pipe(Effect.ignore)
yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session })
SyncEvent.remove(sessionID)
})
} catch (e) {
log.error(e)
}
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (msg: MessageV2.Info) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.Updated, {
sessionID: msg.sessionID,
info: msg,
}),
)
return msg
})
const updatePart = Effect.fn("Session.updatePart")(function* (part: MessageV2.Part) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
}),
)
return part
})
const create = Effect.fn("Session.create")(function* (input?: {
parentID?: SessionID
title?: string
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) {
return yield* createNext({
parentID: input?.parentID,
directory: Instance.directory,
title: input?.title,
permission: input?.permission,
workspaceID: input?.workspaceID,
})
})
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
const original = yield* get(input.sessionID)
const title = getForkedTitle(original.title)
const session = yield* createNext({
directory: Instance.directory,
workspaceID: original.workspaceID,
title,
})
const msgs = yield* messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>()
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
const cloned = yield* updateMessage({
...msg.info,
sessionID: session.id,
id: newID,
...(parentID && { parentID }),
})
for (const part of msg.parts) {
yield* updatePart({
...part,
id: PartID.ascending(),
messageID: cloned.id,
sessionID: session.id,
})
}
}
return session
})
const patch = (sessionID: SessionID, info: Patch) =>
Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() } })
})
const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) {
yield* patch(input.sessionID, { title: input.title })
})
const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
yield* patch(input.sessionID, { time: { archived: input.time } })
})
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID
permission: Permission.Ruleset
}) {
yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } })
})
const setRevert = Effect.fn("Session.setRevert")(function* (input: {
sessionID: SessionID
revert: Info["revert"]
summary: Info["summary"]
}) {
yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert })
})
const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() }, revert: null })
})
const setSummary = Effect.fn("Session.setSummary")(function* (input: {
sessionID: SessionID
summary: Info["summary"]
}) {
yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary })
})
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
)
})
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
return yield* Effect.promise(async () => {
const result = [] as MessageV2.WithParts[]
for await (const msg of MessageV2.stream(input.sessionID)) {
if (input.limit && result.length >= input.limit) break
result.push(msg)
}
result.reverse()
return result
})
})
const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
sessionID: SessionID
messageID: MessageID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
return input.messageID
})
const removePart = Effect.fn("Session.removePart")(function* (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
return input.partID
})
const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
field: string
delta: string
}) {
yield* bus.publish(MessageV2.Event.PartDelta, input)
})
const initialize = Effect.fn("Session.initialize")(function* (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) {
yield* Effect.promise(() =>
SessionPrompt.command({
sessionID: input.sessionID,
messageID: input.messageID,
model: input.providerID + "/" + input.modelID,
command: Command.Default.INIT,
arguments: "",
}),
)
})
return Service.of({
create,
fork,
touch,
get,
share,
unshare,
setTitle,
setArchived,
setPermission,
setRevert,
clearRevert,
setSummary,
diff,
messages,
children,
remove,
updateMessage,
removeMessage,
removePart,
updatePart,
updatePartDelta,
initialize,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(
z
.object({
@@ -242,244 +711,46 @@ export namespace Session {
workspaceID: WorkspaceID.zod.optional(),
})
.optional(),
async (input) => {
return createNext({
parentID: input?.parentID,
directory: Instance.directory,
title: input?.title,
permission: input?.permission,
workspaceID: input?.workspaceID,
})
},
(input) => runPromise((svc) => svc.create(input)),
)
export const fork = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
}),
async (input) => {
const original = await get(input.sessionID)
if (!original) throw new Error("session not found")
const title = getForkedTitle(original.title)
const session = await createNext({
directory: Instance.directory,
workspaceID: original.workspaceID,
title,
})
const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>()
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
const cloned = await updateMessage({
...msg.info,
sessionID: session.id,
id: newID,
...(parentID && { parentID }),
})
for (const part of msg.parts) {
await updatePart({
...part,
id: PartID.ascending(),
messageID: cloned.id,
sessionID: session.id,
})
}
}
return session
},
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
runPromise((svc) => svc.fork(input)),
)
export const touch = fn(SessionID.zod, async (sessionID) => {
const time = Date.now()
SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
})
export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
export async function createNext(input: {
id?: SessionID
title?: string
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
permission?: Permission.Ruleset
}) {
const result: Info = {
id: SessionID.descending(input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
time: {
created: Date.now(),
updated: Date.now(),
},
}
log.info("created", result)
SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
share(result.id).catch(() => {
// Silently ignore sharing errors during session creation
})
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
Bus.publish(Event.Updated, {
sessionID: result.id,
info: result,
})
}
return result
}
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(SessionID.zod, async (id) => {
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
return fromRow(row)
})
export const share = fn(SessionID.zod, async (id) => {
const cfg = await Config.get()
if (cfg.share === "disabled") {
throw new Error("Sharing is disabled in configuration")
}
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
return share
})
export const unshare = fn(SessionID.zod, async (id) => {
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
})
export const setTitle = fn(
z.object({
sessionID: SessionID.zod,
title: z.string(),
}),
async (input) => {
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
},
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)),
)
export const setArchived = fn(
z.object({
sessionID: SessionID.zod,
time: z.number().optional(),
}),
async (input) => {
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
},
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
runPromise((svc) => svc.setArchived(input)),
)
export const setPermission = fn(
z.object({
sessionID: SessionID.zod,
permission: Permission.Ruleset,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: { permission: input.permission, time: { updated: Date.now() } },
})
},
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn(
z.object({
sessionID: SessionID.zod,
revert: Info.shape.revert,
summary: Info.shape.summary,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
summary: input.summary,
time: { updated: Date.now() },
revert: input.revert,
},
})
},
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
)
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
SyncEvent.run(Event.Updated, {
sessionID,
info: {
time: { updated: Date.now() },
revert: null,
},
})
})
export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
export const setSummary = fn(
z.object({
sessionID: SessionID.zod,
summary: Info.shape.summary,
}),
async (input) => {
SyncEvent.run(Event.Updated, {
sessionID: input.sessionID,
info: {
time: { updated: Date.now() },
summary: input.summary,
},
})
},
export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
)
export const diff = fn(SessionID.zod, async (sessionID) => {
try {
return await Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
} catch {
return []
}
})
export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
export const messages = fn(
z.object({
sessionID: SessionID.zod,
limit: z.number().optional(),
}),
async (input) => {
const result = [] as MessageV2.WithParts[]
for await (const msg of MessageV2.stream(input.sessionID)) {
if (input.limit && result.length >= input.limit) break
result.push(msg)
}
result.reverse()
return result
},
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)),
)
export function* list(input?: {
@@ -594,84 +865,20 @@ export namespace Session {
}
}
export const children = fn(SessionID.zod, async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>
db
.select()
.from(SessionTable)
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
.all(),
)
return rows.map(fromRow)
})
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
export const updateMessage = fn(MessageV2.Info, (msg) => runPromise((svc) => svc.updateMessage(msg)))
export const remove = fn(SessionID.zod, async (sessionID) => {
try {
const session = await get(sessionID)
for (const child of await children(sessionID)) {
await remove(child.id)
}
await unshare(sessionID).catch(() => {})
SyncEvent.run(Event.Deleted, { sessionID, info: session })
// Eagerly remove event sourcing data to free up space
SyncEvent.remove(sessionID)
} catch (e) {
log.error(e)
}
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
SyncEvent.run(MessageV2.Event.Updated, {
sessionID: msg.sessionID,
info: msg,
})
return msg
})
export const removeMessage = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input) => {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
return input.messageID
},
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
runPromise((svc) => svc.removeMessage(input)),
)
export const removePart = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
}),
async (input) => {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
return input.partID
},
z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
(input) => runPromise((svc) => svc.removePart(input)),
)
const UpdatePartInput = MessageV2.Part
export const updatePart = fn(UpdatePartInput, async (part) => {
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
})
return part
})
export const updatePart = fn(MessageV2.Part, (part) => runPromise((svc) => svc.updatePart(part)))
export const updatePartDelta = fn(
z.object({
@@ -681,111 +888,11 @@ export namespace Session {
field: z.string(),
delta: z.string(),
}),
async (input) => {
Bus.publish(MessageV2.Event.PartDelta, input)
},
(input) => runPromise((svc) => svc.updatePartDelta(input)),
)
export const getUsage = fn(
z.object({
model: z.custom<Provider.Model>(),
usage: z.custom<LanguageModelV2Usage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = safe(
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)
const total = iife(() => {
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
// don't compute from components
if (
input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
const tokens = {
total,
input: adjustedInputTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,
read: cacheReadInputTokens,
},
}
const costInfo =
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
? input.model.cost.experimentalOver200K
: input.model.cost
return {
cost: safe(
new Decimal(0)
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now:
// charge reasoning tokens at the same rate as output tokens
.add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))
.toNumber(),
),
tokens,
}
},
)
export class BusyError extends Error {
constructor(public readonly sessionID: string) {
super(`Session ${sessionID} is busy`)
}
}
export const initialize = fn(
z.object({
sessionID: SessionID.zod,
modelID: ModelID.zod,
providerID: ProviderID.zod,
messageID: MessageID.zod,
}),
async (input) => {
await SessionPrompt.command({
sessionID: input.sessionID,
messageID: input.messageID,
model: input.providerID + "/" + input.modelID,
command: Command.Default.INIT,
arguments: "",
})
},
z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
(input) => runPromise((svc) => svc.initialize(input)),
)
}
+4 -13
View File
@@ -1,16 +1,6 @@
import { Installation } from "@/installation"
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import {
streamText,
wrapLanguageModel,
type ModelMessage,
type StreamTextResult,
type Tool,
type ToolSet,
tool,
jsonSchema,
} from "ai"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
@@ -23,6 +13,7 @@ import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
export namespace LLM {
const log = Log.create({ service: "llm" })
@@ -43,8 +34,6 @@ export namespace LLM {
toolChoice?: "auto" | "required" | "none"
}
export type StreamOutput = StreamTextResult<ToolSet, unknown>
export async function stream(input: StreamInput) {
const l = log
.clone()
@@ -273,8 +262,10 @@ export namespace LLM {
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// TODO: verify that LanguageModelV3Prompt is still compat here!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
+12 -5
View File
@@ -573,11 +573,11 @@ export namespace MessageV2 {
}))
}
export function toModelMessages(
export async function toModelMessages(
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean },
): ModelMessage[] {
): Promise<ModelMessage[]> {
const result: UIMessage[] = []
const toolNames = new Set<string>()
// Track media from tool results that need to be injected as user messages
@@ -601,7 +601,8 @@ export namespace MessageV2 {
return false
})()
const toModelOutput = (output: unknown) => {
const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => {
const output = options.output
if (typeof output === "string") {
return { type: "text", value: output }
}
@@ -799,7 +800,7 @@ export namespace MessageV2 {
const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))
return convertToModelMessages(
return await convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
@@ -871,7 +872,13 @@ export namespace MessageV2 {
db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
)
return rows.map(
(row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part,
(row) =>
({
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
}) as MessageV2.Part,
)
})
+462 -391
View File
@@ -1,430 +1,501 @@
import { MessageV2 } from "./message-v2"
import { Cause, Effect, Exit, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { makeRuntime } from "@/effect/run-service"
import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { Session } from "."
import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "./summary"
import { Bus } from "@/bus"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { PartID } from "./schema"
import type { SessionID } from "./schema"
import { SessionCompaction } from "./compaction"
import { SessionRetry } from "./retry"
import { SessionStatus } from "./status"
import { Plugin } from "@/plugin"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { Permission } from "@/permission"
import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
export type Info = Awaited<ReturnType<typeof create>>
export type Result = Awaited<ReturnType<Info["process"]>>
export type Result = "compact" | "stop" | "continue"
export function create(input: {
export interface Handle {
readonly message: MessageV2.Assistant
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
}
export interface Info {
readonly message: MessageV2.Assistant
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
readonly process: (streamInput: LLM.StreamInput) => Promise<Result>
}
type Input = {
assistantMessage: MessageV2.Assistant
sessionID: SessionID
model: Provider.Model
abort: AbortSignal
}) {
const toolcalls: Record<string, MessageV2.ToolPart> = {}
let snapshot: string | undefined
let blocked = false
let attempt = 0
let needsCompaction = false
}
const result = {
get message() {
return input.assistantMessage
},
partFromToolCall(toolCallID: string) {
return toolcalls[toolCallID]
},
async process(streamInput: LLM.StreamInput) {
log.info("process")
needsCompaction = false
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
while (true) {
try {
let currentText: MessageV2.TextPart | undefined
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
const stream = await LLM.stream(streamInput)
export interface Interface {
readonly create: (input: Input) => Effect.Effect<Handle>
}
for await (const value of stream.fullStream) {
input.abort.throwIfAborted()
switch (value.type) {
case "start":
await SessionStatus.set(input.sessionID, { type: "busy" })
break
interface ProcessorContext extends Input {
toolcalls: Record<string, MessageV2.ToolPart>
shouldBreak: boolean
snapshot: string | undefined
blocked: boolean
needsCompaction: boolean
currentText: MessageV2.TextPart | undefined
reasoningMap: Record<string, MessageV2.ReasoningPart>
}
case "reasoning-start":
if (value.id in reasoningMap) {
continue
}
const reasoningPart = {
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "reasoning" as const,
text: "",
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
reasoningMap[value.id] = reasoningPart
await Session.updatePart(reasoningPart)
break
type StreamResult = Awaited<ReturnType<typeof LLM.stream>>
type StreamEvent = StreamResult["fullStream"] extends AsyncIterable<infer T> ? T : never
case "reasoning-delta":
if (value.id in reasoningMap) {
const part = reasoningMap[value.id]
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
await Session.updatePartDelta({
sessionID: part.sessionID,
messageID: part.messageID,
partID: part.id,
field: "text",
delta: value.text,
})
}
break
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionProcessor") {}
case "reasoning-end":
if (value.id in reasoningMap) {
const part = reasoningMap[value.id]
part.text = part.text.trimEnd()
export const layer: Layer.Layer<
Service,
never,
| Session.Service
| Config.Service
| Bus.Service
| Snapshot.Service
| Agent.Service
| Permission.Service
| Plugin.Service
| SessionStatus.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const session = yield* Session.Service
const config = yield* Config.Service
const bus = yield* Bus.Service
const snapshot = yield* Snapshot.Service
const agents = yield* Agent.Service
const permission = yield* Permission.Service
const plugin = yield* Plugin.Service
const status = yield* SessionStatus.Service
part.time = {
...part.time,
end: Date.now(),
}
if (value.providerMetadata) part.metadata = value.providerMetadata
await Session.updatePart(part)
delete reasoningMap[value.id]
}
break
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
const ctx: ProcessorContext = {
assistantMessage: input.assistantMessage,
sessionID: input.sessionID,
model: input.model,
abort: input.abort,
toolcalls: {},
shouldBreak: false,
snapshot: undefined,
blocked: false,
needsCompaction: false,
currentText: undefined,
reasoningMap: {},
}
case "tool-input-start":
const part = await Session.updatePart({
id: toolcalls[value.id]?.id ?? PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: {
status: "pending",
input: {},
raw: "",
},
})
toolcalls[value.id] = part as MessageV2.ToolPart
break
const parse = (e: unknown) =>
MessageV2.fromError(e, {
providerID: input.model.providerID,
aborted: input.abort.aborted,
})
case "tool-input-delta":
break
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
switch (value.type) {
case "start":
yield* status.set(ctx.sessionID, { type: "busy" })
return
case "tool-input-end":
break
case "tool-call": {
const match = toolcalls[value.toolCallId]
if (match) {
const part = await Session.updatePart({
...match,
tool: value.toolName,
state: {
status: "running",
input: value.input,
time: {
start: Date.now(),
},
},
metadata: value.providerMetadata,
})
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
const parts = await MessageV2.parts(input.assistantMessage.id)
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
lastThree.length === DOOM_LOOP_THRESHOLD &&
lastThree.every(
(p) =>
p.type === "tool" &&
p.tool === value.toolName &&
p.state.status !== "pending" &&
JSON.stringify(p.state.input) === JSON.stringify(value.input),
)
) {
const agent = await Agent.get(input.assistantMessage.agent)
await Permission.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID,
metadata: {
tool: value.toolName,
input: value.input,
},
always: [value.toolName],
ruleset: agent.permission,
})
}
}
break
}
case "tool-result": {
const match = toolcalls[value.toolCallId]
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
status: "completed",
input: value.input ?? match.state.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
end: Date.now(),
},
attachments: value.output.attachments,
},
})
delete toolcalls[value.toolCallId]
}
break
}
case "tool-error": {
const match = toolcalls[value.toolCallId]
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
status: "error",
input: value.input ?? match.state.input,
error: value.error instanceof Error ? value.error.message : String(value.error),
time: {
start: match.state.time.start,
end: Date.now(),
},
},
})
if (
value.error instanceof Permission.RejectedError ||
value.error instanceof Question.RejectedError
) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]
}
break
}
case "error":
throw value.error
case "start-step":
snapshot = await Snapshot.track()
await Session.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
snapshot,
type: "step-start",
})
break
case "finish-step":
const usage = Session.getUsage({
model: input.model,
usage: value.usage,
metadata: value.providerMetadata,
})
input.assistantMessage.finish = value.finishReason
input.assistantMessage.cost += usage.cost
input.assistantMessage.tokens = usage.tokens
await Session.updatePart({
id: PartID.ascending(),
reason: value.finishReason,
snapshot: await Snapshot.track(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
})
await Session.updateMessage(input.assistantMessage)
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
if (patch.files.length) {
await Session.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "patch",
hash: patch.hash,
files: patch.files,
})
}
snapshot = undefined
}
SessionSummary.summarize({
sessionID: input.sessionID,
messageID: input.assistantMessage.parentID,
})
if (
!input.assistantMessage.summary &&
(await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model }))
) {
needsCompaction = true
}
break
case "text-start":
currentText = {
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
type: "text",
text: "",
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
await Session.updatePart(currentText)
break
case "text-delta":
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
await Session.updatePartDelta({
sessionID: currentText.sessionID,
messageID: currentText.messageID,
partID: currentText.id,
field: "text",
delta: value.text,
})
}
break
case "text-end":
if (currentText) {
currentText.text = currentText.text.trimEnd()
const textOutput = await Plugin.trigger(
"experimental.text.complete",
{
sessionID: input.sessionID,
messageID: input.assistantMessage.id,
partID: currentText.id,
},
{ text: currentText.text },
)
currentText.text = textOutput.text
currentText.time = {
start: Date.now(),
end: Date.now(),
}
if (value.providerMetadata) currentText.metadata = value.providerMetadata
await Session.updatePart(currentText)
}
currentText = undefined
break
case "finish":
break
default:
log.info("unhandled", {
...value,
})
continue
}
if (needsCompaction) break
}
} catch (e: any) {
log.error("process", {
error: e,
stack: JSON.stringify(e.stack),
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
if (MessageV2.ContextOverflowError.isInstance(error)) {
needsCompaction = true
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error,
})
} else {
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
await SessionStatus.set(input.sessionID, {
type: "retry",
attempt,
message: retry,
next: Date.now() + delay,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
continue
}
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
await SessionStatus.set(input.sessionID, { type: "idle" })
}
}
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
if (patch.files.length) {
await Session.updatePart({
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
ctx.reasoningMap[value.id] = {
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "reasoning",
text: "",
time: { start: Date.now() },
metadata: value.providerMetadata,
}
yield* session.updatePart(ctx.reasoningMap[value.id])
return
case "reasoning-delta":
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePartDelta({
sessionID: ctx.reasoningMap[value.id].sessionID,
messageID: ctx.reasoningMap[value.id].messageID,
partID: ctx.reasoningMap[value.id].id,
field: "text",
delta: value.text,
})
return
case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id])
delete ctx.reasoningMap[value.id]
return
case "tool-input-start":
ctx.toolcalls[value.id] = (yield* session.updatePart({
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: { status: "pending", input: {}, raw: "" },
})) as MessageV2.ToolPart
return
case "tool-input-delta":
return
case "tool-input-end":
return
case "tool-call": {
const match = ctx.toolcalls[value.toolCallId]
if (!match) return
ctx.toolcalls[value.toolCallId] = (yield* session.updatePart({
...match,
tool: value.toolName,
state: { status: "running", input: value.input, time: { start: Date.now() } },
metadata: value.providerMetadata,
})) as MessageV2.ToolPart
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.toolName &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(value.input),
)
) {
return
}
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.toolName, input: value.input },
always: [value.toolName],
ruleset: agent.permission,
})
return
}
case "tool-result": {
const match = ctx.toolcalls[value.toolCallId]
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "completed",
input: value.input ?? match.state.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: { start: match.state.time.start, end: Date.now() },
attachments: value.output.attachments,
},
})
delete ctx.toolcalls[value.toolCallId]
return
}
case "tool-error": {
const match = ctx.toolcalls[value.toolCallId]
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "error",
input: value.input ?? match.state.input,
error: value.error instanceof Error ? value.error.message : String(value.error),
time: { start: match.state.time.start, end: Date.now() },
},
})
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
delete ctx.toolcalls[value.toolCallId]
return
}
case "error":
throw value.error
case "start-step":
ctx.snapshot = yield* snapshot.track()
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.sessionID,
snapshot: ctx.snapshot,
type: "step-start",
})
return
case "finish-step": {
const usage = Session.getUsage({
model: ctx.model,
usage: value.usage,
metadata: value.providerMetadata,
})
ctx.assistantMessage.finish = value.finishReason
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({
id: PartID.ascending(),
reason: value.finishReason,
snapshot: yield* snapshot.track(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
})
yield* session.updateMessage(ctx.assistantMessage)
if (ctx.snapshot) {
const patch = yield* snapshot.patch(ctx.snapshot)
if (patch.files.length) {
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.sessionID,
type: "patch",
hash: patch.hash,
files: patch.files,
})
}
ctx.snapshot = undefined
}
yield* Effect.sync(() => {
void SessionSummary.summarize({
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.parentID,
})
})
if (
!ctx.assistantMessage.summary &&
(yield* Effect.promise(() => SessionCompaction.isOverflow({ tokens: usage.tokens, model: ctx.model })))
) {
ctx.needsCompaction = true
}
return
}
case "text-start":
ctx.currentText = {
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "text",
text: "",
time: { start: Date.now() },
metadata: value.providerMetadata,
}
yield* session.updatePart(ctx.currentText)
return
case "text-delta":
if (!ctx.currentText) return
ctx.currentText.text += value.text
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePartDelta({
sessionID: ctx.currentText.sessionID,
messageID: ctx.currentText.messageID,
partID: ctx.currentText.id,
field: "text",
delta: value.text,
})
return
case "text-end":
if (!ctx.currentText) return
ctx.currentText.text = ctx.currentText.text.trimEnd()
ctx.currentText.text = (yield* plugin.trigger(
"experimental.text.complete",
{
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
partID: ctx.currentText.id,
},
{ text: ctx.currentText.text },
)).text
ctx.currentText.time = { start: Date.now(), end: Date.now() }
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePart(ctx.currentText)
ctx.currentText = undefined
return
case "finish":
return
default:
log.info("unhandled", { ...value })
return
}
})
const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
if (ctx.snapshot) {
const patch = yield* snapshot.patch(ctx.snapshot)
if (patch.files.length) {
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.sessionID,
type: "patch",
hash: patch.hash,
files: patch.files,
})
}
snapshot = undefined
ctx.snapshot = undefined
}
const p = await MessageV2.parts(input.assistantMessage.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
await Session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
},
})
}
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
for (const part of parts) {
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
yield* session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
time: { start: Date.now(), end: Date.now() },
},
})
}
input.assistantMessage.time.completed = Date.now()
await Session.updateMessage(input.assistantMessage)
if (needsCompaction) return "compact"
if (blocked) return "stop"
if (input.assistantMessage.error) return "stop"
ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage)
})
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
log.error("process", { error: e, stack: JSON.stringify((e as any)?.stack) })
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return
}
ctx.assistantMessage.error = error
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID,
error: ctx.assistantMessage.error,
})
yield* status.set(ctx.sessionID, { type: "idle" })
})
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
log.info("process")
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
const stream = yield* Effect.promise(() => LLM.stream(streamInput))
yield* Stream.fromAsyncIterable(stream.fullStream, (e) => e).pipe(
Stream.runForEachWhile((event) =>
Effect.gen(function* () {
// TODO: Revisit this once more of handleEvent is Effect-native.
// As local synchronous mutation shrinks, this guard and eventually the
// raw AbortSignal plumbing should be removable.
input.abort.throwIfAborted()
yield* handleEvent(event)
return !ctx.needsCompaction
}),
),
)
}).pipe(
Effect.catchCause((cause) => Effect.fail(Cause.squash(cause))),
Effect.retry(SessionRetry.policy({ sessionID: ctx.sessionID, parse })),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => halt(Cause.squash(cause)),
),
Effect.catchCause(() => Effect.interrupt),
Effect.onInterrupt(() => halt(new DOMException("Aborted", "AbortError"))),
Effect.ensuring(cleanup()),
)
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error || input.abort.aborted) return "stop"
return "continue"
})
return {
get message() {
return ctx.assistantMessage
},
partFromToolCall(toolCallID: string) {
return ctx.toolcalls[toolCallID]
},
process,
} satisfies Handle
})
return Service.of({ create })
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Permission.layer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function create(input: Input): Promise<Info> {
const hit = await runPromise((svc) => svc.create(input))
return {
get message() {
return hit.message
},
partFromToolCall(toolCallID: string) {
return hit.partFromToolCall(toolCallID)
},
async process(streamInput: LLM.StreamInput) {
const exit = await Effect.runPromiseExit(hit.process(streamInput), { signal: input.abort })
if (Exit.isFailure(exit)) {
if (Cause.hasInterrupts(exit.cause) && input.abort.aborted) return "stop"
throw Cause.squash(exit.cause)
}
return exit.value
},
}
return result
}
}
+39 -30
View File
@@ -11,7 +11,7 @@ import { Session } from "."
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
@@ -321,7 +321,13 @@ export namespace SessionPrompt {
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
![
"tool-calls",
// in v6 unknown became other but other existed in v5 too and was distinctly different
// I think there are certain providers that used to have bad stop reasons, not rlly sure which
// ones if any still have this?
// "unknown",
].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
@@ -588,7 +594,7 @@ export namespace SessionPrompt {
session,
})
const processor = SessionProcessor.create({
const processor = await SessionProcessor.create({
assistantMessage: (await Session.updateMessage({
id: MessageID.ascending(),
parentID: lastUser.id,
@@ -692,7 +698,7 @@ export namespace SessionPrompt {
sessionID,
system,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(await MessageV2.toModelMessages(msgs, model)),
...(isLastStep
? [
{
@@ -775,7 +781,7 @@ export namespace SessionPrompt {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const context = (args: any, options: ToolCallOptions): Tool.Context => ({
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
@@ -861,7 +867,8 @@ export namespace SessionPrompt {
const execute = item.execute
if (!execute) continue
const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema)
const schema = await asSchema(item.inputSchema).jsonSchema
const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed)
// Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => {
@@ -974,10 +981,10 @@ export namespace SessionPrompt {
metadata: { valid: true },
}
},
toModelOutput(result) {
toModelOutput({ output }) {
return {
type: "text",
value: result.output,
value: output.output,
}
},
})
@@ -2010,28 +2017,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
)
})
const result = await LLM.stream({
agent,
user: firstRealUser.info as MessageV2.User,
system: [],
small: true,
tools: {},
model,
abort: new AbortController().signal,
sessionID: input.session.id,
retries: 2,
messages: [
{
role: "user",
content: "Generate a title for this conversation:\n",
},
...(hasOnlySubtaskParts
? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }]
: MessageV2.toModelMessages(contextMessages, model)),
],
})
const text = await result.text.catch((err) => log.error("failed to generate title", { error: err }))
if (text) {
try {
const result = await LLM.stream({
agent,
user: firstRealUser.info as MessageV2.User,
system: [],
small: true,
tools: {},
model,
abort: new AbortController().signal,
sessionID: input.session.id,
retries: 2,
messages: [
{
role: "user",
content: "Generate a title for this conversation:\n",
},
...(hasOnlySubtaskParts
? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }]
: await MessageV2.toModelMessages(contextMessages, model)),
],
})
const text = await result.text
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n")
@@ -2044,6 +2051,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (NotFoundError.isInstance(err)) return
throw err
})
} catch (error) {
log.error("failed to generate title", { error })
}
}
}
+42 -21
View File
@@ -1,28 +1,20 @@
import type { NamedError } from "@opencode-ai/util/error"
import { Clock, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import type { SessionID } from "./schema"
import { SessionStatus } from "./status"
import { iife } from "@/util/iife"
export namespace SessionRetry {
export type Err = ReturnType<NamedError["toObject"]>
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abortHandler = () => {
clearTimeout(timeout)
reject(new DOMException("Aborted", "AbortError"))
}
const timeout = setTimeout(
() => {
signal.removeEventListener("abort", abortHandler)
resolve()
},
Math.min(ms, RETRY_MAX_DELAY),
)
signal.addEventListener("abort", abortHandler, { once: true })
})
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
export function delay(attempt: number, error?: MessageV2.APIError) {
@@ -33,7 +25,7 @@ export namespace SessionRetry {
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) {
return parsedMs
return cap(parsedMs)
}
}
@@ -42,23 +34,23 @@ export namespace SessionRetry {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) {
// convert seconds to milliseconds
return Math.ceil(parsedSeconds * 1000)
return cap(Math.ceil(parsedSeconds * 1000))
}
// Try parsing as HTTP date format
const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) {
return Math.ceil(parsed)
return cap(Math.ceil(parsed))
}
}
return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
}
}
return Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: ReturnType<NamedError["toObject"]>) {
export function retryable(error: Err) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
@@ -98,4 +90,33 @@ export namespace SessionRetry {
return undefined
}
}
export function policy(opts: { sessionID: SessionID; parse: (error: unknown) => Err }) {
let attempt = 0
return Schedule.identity<unknown>().pipe(
Schedule.while(({ input }: Schedule.InputMetadata<unknown>) =>
Effect.succeed(retryable(opts.parse(input)) !== undefined),
),
Schedule.addDelay((input) =>
Effect.gen(function* () {
const error = opts.parse(input)
const message = retryable(error)
attempt += 1
const wait = delay(attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
if (message) {
yield* Effect.promise(() =>
SessionStatus.set(opts.sessionID, {
type: "retry",
attempt,
message,
next: now + wait,
}),
)
}
return wait
}),
),
)
}
}
@@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "mixed-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.mixed",
server: async () => ({}),
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec],
plugin_meta: {
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
+41 -70
View File
@@ -1,7 +1,15 @@
import { describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../../fixture/fixture"
import * as App from "../../../src/cli/cmd/tui/app"
import { Rpc } from "../../../src/util/rpc"
import { UI } from "../../../src/cli/ui"
import * as Timeout from "../../../src/util/timeout"
import * as Network from "../../../src/cli/network"
import * as Win32 from "../../../src/cli/cmd/tui/win32"
import { TuiConfig } from "../../../src/config/tui"
import { Instance } from "../../../src/project/instance"
const stop = new Error("stop")
const seen = {
@@ -9,81 +17,43 @@ const seen = {
inst: [] as string[],
}
mock.module("../../../src/cli/cmd/tui/app", () => ({
tui: async (input: { directory: string }) => {
seen.tui.push(input.directory)
function setup() {
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
// and mock.restore() does not reset mock.module values. If this switches back
// to module mocks, later suites can see mocked @/config/tui and fail (e.g.
// plugin-loader tests expecting real TuiConfig.waitForDependencies). See:
// https://github.com/oven-sh/bun/issues/7823 and #12823.
spyOn(App, "tui").mockImplementation(async (input) => {
if (input.directory) seen.tui.push(input.directory)
throw stop
},
}))
mock.module("@/util/rpc", () => ({
Rpc: {
client: () => ({
call: async () => ({ url: "http://127.0.0.1" }),
on: () => {},
}),
},
}))
mock.module("@/cli/ui", () => ({
UI: {
error: () => {},
},
}))
mock.module("@/util/log", () => ({
Log: {
init: async () => {},
create: () => ({
error: () => {},
info: () => {},
warn: () => {},
debug: () => {},
time: () => ({ stop: () => {} }),
}),
Default: {
error: () => {},
info: () => {},
warn: () => {},
debug: () => {},
},
},
}))
mock.module("@/util/timeout", () => ({
withTimeout: <T>(input: Promise<T>) => input,
}))
mock.module("@/cli/network", () => ({
withNetworkOptions: <T>(input: T) => input,
resolveNetworkOptions: async () => ({
})
spyOn(Rpc, "client").mockImplementation(() => ({
call: async () => ({ url: "http://127.0.0.1" }) as never,
on: () => () => {},
}))
spyOn(UI, "error").mockImplementation(() => {})
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
mdns: false,
port: 0,
hostname: "127.0.0.1",
}),
}))
mock.module("../../../src/cli/cmd/tui/win32", () => ({
win32DisableProcessedInput: () => {},
win32InstallCtrlCGuard: () => undefined,
}))
mock.module("@/config/tui", () => ({
TuiConfig: {
get: () => ({}),
},
}))
mock.module("@/project/instance", () => ({
Instance: {
provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
seen.inst.push(input.directory)
return input.fn()
},
},
}))
mdnsDomain: "opencode.local",
cors: [],
})
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
spyOn(TuiConfig, "get").mockResolvedValue({})
spyOn(Instance, "provide").mockImplementation(async (input) => {
seen.inst.push(input.directory)
return input.fn()
})
}
describe("tui thread", () => {
afterEach(() => {
mock.restore()
})
async function call(project?: string) {
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
@@ -107,6 +77,7 @@ describe("tui thread", () => {
}
async function check(project?: string) {
setup()
await using tmp = await tmpdir({ git: true })
const cwd = process.cwd()
const pwd = process.env.PWD
+19 -10
View File
@@ -821,9 +821,12 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
})
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
calls += 1
start()
await gate
const hit = path.normalize(opts?.cwd ?? "") === path.normalize(dir)
if (hit) {
calls += 1
start()
await gate
}
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
@@ -883,12 +886,16 @@ test("serializes config dependency installs across dirs", async () => {
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
start()
await gate
const cwd = path.normalize(opts?.cwd ?? "")
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
start()
await gate
}
}
const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
@@ -896,7 +903,9 @@ test("serializes config dependency installs across dirs", async () => {
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
open -= 1
if (hit) {
open -= 1
}
return {
code: 0,
stdout: Buffer.alloc(0),
+3 -7
View File
@@ -1,17 +1,13 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Format } from "../../src/format"
import { Config } from "../../src/config/config"
import * as Formatter from "../../src/format/formatter"
const node = NodeChildProcessSpawner.layer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const it = testEffect(Layer.mergeAll(Format.layer, node).pipe(Layer.provide(Config.defaultLayer)))
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
describe("Format", () => {
it.effect("status() returns built-in formatters when no config overrides", () =>
@@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
file,
[
"export default {",
' id: "demo.v1-default",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "default")`,
" return {}",
@@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
})
test("rejects v1 file server plugin without id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
})
test("rejects v1 plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.mixed",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "server")`,
" return {}",
" },",
" tui: async () => {},",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
})
test("resolves npm plugin specs with explicit and default versions", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1,6 +1,6 @@
import { OpenAICompatibleChatLanguageModel } from "@/provider/sdk/copilot/chat/openai-compatible-chat-language-model"
import { describe, test, expect, mock } from "bun:test"
import type { LanguageModelV2Prompt } from "@ai-sdk/provider"
import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader()
@@ -13,7 +13,7 @@ async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promi
return result
}
const TEST_PROMPT: LanguageModelV2Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
// Fixtures from copilot_test.exs
const FIXTURES = {
@@ -123,7 +123,7 @@ describe("doStream", () => {
{ type: "text-delta", id: "txt-0", delta: " world" },
{ type: "text-delta", id: "txt-0", delta: "!" },
{ type: "text-end", id: "txt-0" },
{ type: "finish", finishReason: "stop" },
{ type: "finish", finishReason: { unified: "stop" } },
])
})
@@ -201,10 +201,10 @@ describe("doStream", () => {
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: "tool-calls",
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: 19581,
outputTokens: 53,
inputTokens: { total: 19581 },
outputTokens: { total: 53 },
},
})
})
@@ -256,10 +256,10 @@ describe("doStream", () => {
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: "stop",
finishReason: { unified: "stop" },
usage: {
inputTokens: 5778,
outputTokens: 59,
inputTokens: { total: 5778 },
outputTokens: { total: 59 },
},
providerMetadata: {
copilot: {
@@ -315,7 +315,7 @@ describe("doStream", () => {
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: "stop",
finishReason: { unified: "stop" },
})
})
@@ -388,10 +388,10 @@ describe("doStream", () => {
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: "tool-calls",
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: 3767,
outputTokens: 19,
inputTokens: { total: 3767 },
outputTokens: { total: 19 },
},
})
})
@@ -449,7 +449,7 @@ describe("doStream", () => {
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: "tool-calls",
finishReason: { unified: "tool-calls" },
})
})
+394 -390
View File
@@ -1,408 +1,412 @@
import { test, expect, describe } from "bun:test"
import path from "path"
// TODO: UNCOMMENT WHEN GITLAB SUPPORT IS COMPLETED
//
//
//
// import { test, expect, describe } from "bun:test"
// import path from "path"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Global } from "../../src/global"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
// import { ProviderID, ModelID } from "../../src/provider/schema"
// import { tmpdir } from "../fixture/fixture"
// import { Instance } from "../../src/project/instance"
// import { Provider } from "../../src/provider/provider"
// import { Env } from "../../src/env"
// import { Global } from "../../src/global"
// import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
test("GitLab Duo: loads provider with API key from environment", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-gitlab-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
},
})
})
// test("GitLab Duo: loads provider with API key from environment", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-gitlab-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
// },
// })
// })
test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
instanceUrl: "https://gitlab.example.com",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
},
})
})
// test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// provider: {
// gitlab: {
// options: {
// instanceUrl: "https://gitlab.example.com",
// },
// },
// },
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
// },
// })
// })
test("GitLab Duo: loads with OAuth token from auth.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
// test("GitLab Duo: loads with OAuth token from auth.json", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// }),
// )
// },
// })
const authPath = path.join(Global.Path.data, "auth.json")
await Bun.write(
authPath,
JSON.stringify({
gitlab: {
type: "oauth",
access: "test-access-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
},
}),
)
// const authPath = path.join(Global.Path.data, "auth.json")
// await Bun.write(
// authPath,
// JSON.stringify({
// gitlab: {
// type: "oauth",
// access: "test-access-token",
// refresh: "test-refresh-token",
// expires: Date.now() + 3600000,
// },
// }),
// )
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
},
})
})
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// },
// })
// })
test("GitLab Duo: loads with Personal Access Token from auth.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
// test("GitLab Duo: loads with Personal Access Token from auth.json", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// }),
// )
// },
// })
const authPath2 = path.join(Global.Path.data, "auth.json")
await Bun.write(
authPath2,
JSON.stringify({
gitlab: {
type: "api",
key: "glpat-test-pat-token",
},
}),
)
// const authPath2 = path.join(Global.Path.data, "auth.json")
// await Bun.write(
// authPath2,
// JSON.stringify({
// gitlab: {
// type: "api",
// key: "glpat-test-pat-token",
// },
// }),
// )
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
},
})
})
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
// },
// })
// })
test("GitLab Duo: supports self-hosted instance configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
instanceUrl: "https://gitlab.company.internal",
apiKey: "glpat-internal-token",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
},
})
})
// test("GitLab Duo: supports self-hosted instance configuration", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// provider: {
// gitlab: {
// options: {
// instanceUrl: "https://gitlab.company.internal",
// apiKey: "glpat-internal-token",
// },
// },
// },
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
// },
// })
// })
test("GitLab Duo: config apiKey takes precedence over environment variable", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
apiKey: "config-token",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "env-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
},
})
})
// test("GitLab Duo: config apiKey takes precedence over environment variable", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// provider: {
// gitlab: {
// options: {
// apiKey: "config-token",
// },
// },
// },
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "env-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// },
// })
// })
test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
"context-1m-2025-08-07",
)
},
})
})
// test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
// "context-1m-2025-08-07",
// )
// },
// })
// })
test("GitLab Duo: supports feature flags configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
},
})
})
// test("GitLab Duo: supports feature flags configuration", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// provider: {
// gitlab: {
// options: {
// featureFlags: {
// duo_agent_platform_agentic_chat: true,
// duo_agent_platform: true,
// },
// },
// },
// },
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
// },
// })
// })
test("GitLab Duo: has multiple agentic chat models available", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
const models = Object.keys(providers[ProviderID.gitlab].models)
expect(models.length).toBeGreaterThan(0)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")
expect(models).toContain("duo-chat-opus-4-5")
},
})
})
// test("GitLab Duo: has multiple agentic chat models available", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(
// path.join(dir, "opencode.json"),
// JSON.stringify({
// $schema: "https://opencode.ai/config.json",
// }),
// )
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models.length).toBeGreaterThan(0)
// expect(models).toContain("duo-chat-haiku-4-5")
// expect(models).toContain("duo-chat-sonnet-4-5")
// expect(models).toContain("duo-chat-opus-4-5")
// },
// })
// })
describe("GitLab Duo: workflow model routing", () => {
test("duo-workflow-* model routes through workflowChat", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const gitlab = providers[ProviderID.gitlab]
expect(gitlab).toBeDefined()
gitlab.models["duo-workflow-sonnet-4-6"] = {
id: ModelID.make("duo-workflow-sonnet-4-6"),
providerID: ProviderID.make("gitlab"),
name: "Agent Platform (Claude Sonnet 4.6)",
family: "",
api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
status: "active",
headers: {},
options: { workflowRef: "claude_sonnet_4_6" },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200000, output: 64000 },
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {},
}
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
expect(model).toBeDefined()
expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
},
})
})
// describe("GitLab Duo: workflow model routing", () => {
// test("duo-workflow-* model routes through workflowChat", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// const gitlab = providers[ProviderID.gitlab]
// expect(gitlab).toBeDefined()
// gitlab.models["duo-workflow-sonnet-4-6"] = {
// id: ModelID.make("duo-workflow-sonnet-4-6"),
// providerID: ProviderID.make("gitlab"),
// name: "Agent Platform (Claude Sonnet 4.6)",
// family: "",
// api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
// status: "active",
// headers: {},
// options: { workflowRef: "claude_sonnet_4_6" },
// cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
// limit: { context: 200000, output: 64000 },
// capabilities: {
// temperature: false,
// reasoning: true,
// attachment: true,
// toolcall: true,
// input: { text: true, audio: false, image: true, video: false, pdf: true },
// output: { text: true, audio: false, image: false, video: false, pdf: false },
// interleaved: false,
// },
// release_date: "",
// variants: {},
// }
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
// expect(model).toBeDefined()
// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
// const language = await Provider.getLanguage(model)
// expect(language).toBeDefined()
// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
// },
// })
// })
test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
expect(model).toBeDefined()
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
},
})
})
// test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined()
// const language = await Provider.getLanguage(model)
// expect(language).toBeDefined()
// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
// },
// })
// })
test("model.options merged with provider.options in getLanguage", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const gitlab = providers[ProviderID.gitlab]
expect(gitlab.options?.featureFlags).toBeDefined()
const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
expect(model).toBeDefined()
expect(model.options).toBeDefined()
},
})
})
})
// test("model.options merged with provider.options in getLanguage", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// const gitlab = providers[ProviderID.gitlab]
// expect(gitlab.options?.featureFlags).toBeDefined()
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined()
// expect(model.options).toBeDefined()
// },
// })
// })
// })
describe("GitLab Duo: static models", () => {
test("static duo-chat models always present regardless of discovery", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
const models = Object.keys(providers[ProviderID.gitlab].models)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")
expect(models).toContain("duo-chat-opus-4-5")
},
})
})
})
// describe("GitLab Duo: static models", () => {
// test("static duo-chat models always present regardless of discovery", async () => {
// await using tmp = await tmpdir({
// init: async (dir) => {
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
// },
// })
// await Instance.provide({
// directory: tmp.path,
// init: async () => {
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await Provider.list()
// const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models).toContain("duo-chat-haiku-4-5")
// expect(models).toContain("duo-chat-sonnet-4-5")
// expect(models).toContain("duo-chat-opus-4-5")
// },
// })
// })
// })
@@ -1,15 +1,30 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import path from "path"
import { Bus } from "../../src/bus"
import { SessionCompaction } from "../../src/session/compaction"
import { Token } from "../../src/util/token"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Provider } from "../../src/provider/provider"
import * as ProviderModule from "../../src/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
Log.init({ print: false })
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
afterEach(() => {
mock.restore()
})
function createModel(opts: {
context: number
output: number
@@ -40,6 +55,105 @@ function createModel(opts: {
} as Provider.Model
}
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
await Session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "build",
agent: "build",
path: { cwd: root, root },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID,
time: { created: Date.now() },
finish: "end_turn",
}
await Session.updateMessage(msg)
return msg
}
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
return Session.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
type: "tool",
callID: crypto.randomUUID(),
tool,
state: {
status: "completed",
input: {},
output,
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
}
function fake(
input: Parameters<(typeof SessionProcessorModule.SessionProcessor)["create"]>[0],
result: "continue" | "compact",
): ReturnType<(typeof SessionProcessorModule.SessionProcessor)["create"]> {
const msg = input.assistantMessage
return Promise.resolve({
get message() {
return msg
},
partFromToolCall() {
return {
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "tool",
callID: "fake",
tool: "fake",
state: { status: "pending", input: {}, raw: "" },
}
},
process: async () => result,
})
}
function wait(ms = 50) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function defer() {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe("session.compaction.isOverflow", () => {
test("returns true when token count exceeds usable context", async () => {
await using tmp = await tmpdir()
@@ -227,6 +341,272 @@ describe("session.compaction.isOverflow", () => {
})
})
describe("session.compaction.create", () => {
test("creates a compaction user message and part", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1)
expect(msgs[0].parts[0]).toMatchObject({
type: "compaction",
auto: true,
overflow: true,
})
},
})
})
})
describe("session.compaction.prune", () => {
test("compacts old completed tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "bash", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
await SessionCompaction.prune({ sessionID: session.id })
const msgs = await Session.messages({ sessionID: session.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
},
})
})
test("skips protected skill tool output", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const a = await user(session.id, "first")
const b = await assistant(session.id, a.id, tmp.path)
await tool(session.id, b.id, "skill", "x".repeat(200_000))
await user(session.id, "second")
await user(session.id, "third")
await SessionCompaction.prune({ sessionID: session.id })
const msgs = await Session.messages({ sessionID: session.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeUndefined()
}
},
})
})
})
describe("session.compaction.process", () => {
test("publishes compacted event on continue", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const done = defer()
let seen = false
const unsub = Bus.subscribe(SessionCompaction.Event.Compacted, (evt) => {
if (evt.properties.sessionID !== session.id) return
seen = true
done.resolve()
})
const result = await SessionCompaction.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
abort: new AbortController().signal,
auto: false,
})
await Promise.race([
done.promise,
wait(500).then(() => {
throw new Error("timed out waiting for compacted event")
}),
])
unsub()
expect(result).toBe("continue")
expect(seen).toBe(true)
},
})
})
test("marks summary message as errored on compact result", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "compact"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: false,
})
const summary = (await Session.messages({ sessionID: session.id })).find(
(msg) => msg.info.role === "assistant" && msg.info.summary,
)
expect(result).toBe("stop")
expect(summary?.info.role).toBe("assistant")
if (summary?.info.role === "assistant") {
expect(summary.info.finish).toBe("error")
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
}
},
})
})
test("adds synthetic continue prompt when auto is enabled", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
const msg = await user(session.id, "hello")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
})
const msgs = await Session.messages({ sessionID: session.id })
const last = msgs.at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
expect(last?.parts[0]).toMatchObject({
type: "text",
synthetic: true,
})
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("Continue if you have next steps")
}
},
})
})
test("replays the prior user turn on overflow when earlier context exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
await user(session.id, "root")
const replay = await user(session.id, "image")
await Session.updatePart({
id: PartID.ascending(),
messageID: replay.id,
sessionID: session.id,
type: "file",
mime: "image/png",
filename: "cat.png",
url: "https://example.com/cat.png",
})
const msg = await user(session.id, "current")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
overflow: true,
})
const last = (await Session.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
expect(last?.parts.some((part) => part.type === "file")).toBe(false)
expect(
last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
).toBe(true)
},
})
})
test("falls back to overflow guidance when no replayable turn exists", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
spyOn(SessionProcessorModule.SessionProcessor, "create").mockImplementation((input) => fake(input, "continue"))
const session = await Session.create({})
await user(session.id, "earlier")
const msg = await user(session.id, "current")
const result = await SessionCompaction.process({
parentID: msg.id,
messages: await Session.messages({ sessionID: session.id }),
sessionID: session.id,
abort: new AbortController().signal,
auto: true,
overflow: true,
})
const last = (await Session.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
if (last?.parts[0]?.type === "text") {
expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
}
},
})
})
})
describe("util.token.estimate", () => {
test("estimates tokens from text (4 chars per token)", () => {
const text = "x".repeat(4000)
+125 -2
View File
@@ -3,7 +3,6 @@ import path from "path"
import { tool, type ModelMessage } from "ai"
import z from "zod"
import { LLM } from "../../src/session/llm"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderTransform } from "../../src/provider/transform"
@@ -535,6 +534,130 @@ describe("session.llm.stream", () => {
})
})
test("accepts user image attachments as data URLs for OpenAI models", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("openai", "gpt-5.2")
const model = source.model
const chunks = [
{
type: "response.created",
response: {
id: "resp-data-url",
created_at: Math.floor(Date.now() / 1000),
model: model.id,
service_tier: null,
},
},
{
type: "response.output_text.delta",
item_id: "item-data-url",
delta: "Looks good",
logprobs: null,
},
{
type: "response.completed",
response: {
incomplete_details: null,
usage: {
input_tokens: 1,
input_tokens_details: null,
output_tokens: 1,
output_tokens_details: null,
},
service_tier: null,
},
},
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
const image = `data:image/png;base64,${Buffer.from(
await Bun.file(path.join(import.meta.dir, "../tool/fixtures/large-image.png")).arrayBuffer(),
).toString("base64")}`
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["openai"],
provider: {
openai: {
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-openai-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-data-url"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User
const stream = await LLM.stream({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [
{
role: "user",
content: [
{ type: "text", text: "Describe this image" },
{
type: "file",
mediaType: "image/png",
filename: "large-image.png",
data: image,
},
],
},
] as ModelMessage[],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
},
})
})
test("sends messages API payload for Anthropic models", async () => {
const server = state.server
if (!server) {
@@ -625,7 +748,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.7") },
} satisfies MessageV2.User
const stream = await LLM.stream({
@@ -108,7 +108,7 @@ function basePart(messageID: string, id: string) {
}
describe("session.message-v2.toModelMessage", () => {
test("filters out messages with no parts", () => {
test("filters out messages with no parts", async () => {
const input: MessageV2.WithParts[] = [
{
info: userInfo("m-empty"),
@@ -126,7 +126,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "hello" }],
@@ -134,7 +134,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("filters out messages with only ignored parts", () => {
test("filters out messages with only ignored parts", async () => {
const messageID = "m-user"
const input: MessageV2.WithParts[] = [
@@ -151,10 +151,10 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("includes synthetic text parts", () => {
test("includes synthetic text parts", async () => {
const messageID = "m-user"
const input: MessageV2.WithParts[] = [
@@ -182,7 +182,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "hello" }],
@@ -194,7 +194,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("converts user text/file parts and injects compaction/subtask prompts", () => {
test("converts user text/file parts and injects compaction/subtask prompts", async () => {
const messageID = "m-user"
const input: MessageV2.WithParts[] = [
@@ -249,7 +249,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [
@@ -267,7 +267,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => {
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -319,7 +319,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -359,7 +359,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("omits provider metadata when assistant model differs", () => {
test("omits provider metadata when assistant model differs", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -402,7 +402,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -434,7 +434,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("replaces compacted tool output with placeholder", () => {
test("replaces compacted tool output with placeholder", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -470,7 +470,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -501,7 +501,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("converts assistant tool error into error-text tool result", () => {
test("converts assistant tool error into error-text tool result", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -537,7 +537,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -570,7 +570,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("filters assistant messages with non-abort errors", () => {
test("filters assistant messages with non-abort errors", async () => {
const assistantID = "m-assistant"
const input: MessageV2.WithParts[] = [
@@ -590,10 +590,10 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
test("includes aborted assistant messages only when they have non-step-start/reasoning content", async () => {
const assistantID1 = "m-assistant-1"
const assistantID2 = "m-assistant-2"
@@ -633,7 +633,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [
@@ -644,7 +644,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("splits assistant messages on step-start boundaries", () => {
test("splits assistant messages on step-start boundaries", async () => {
const assistantID = "m-assistant"
const input: MessageV2.WithParts[] = [
@@ -669,7 +669,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [{ type: "text", text: "first" }],
@@ -681,7 +681,7 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("drops messages that only contain step-start parts", () => {
test("drops messages that only contain step-start parts", async () => {
const assistantID = "m-assistant"
const input: MessageV2.WithParts[] = [
@@ -696,10 +696,10 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
test("converts pending/running tool calls to error results to prevent dangling tool_use", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -743,7 +743,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
const result = MessageV2.toModelMessages(input, model)
const result = await MessageV2.toModelMessages(input, model)
expect(result).toStrictEqual([
{
+35 -15
View File
@@ -2,9 +2,14 @@ import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule } from "effect"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const providerID = ProviderID.make("test")
@@ -69,24 +74,39 @@ describe("session.retry.delay", () => {
expect(SessionRetry.delay(1, longError)).toBe(700000)
})
test("sleep caps delay to max 32-bit signed integer to avoid TimeoutOverflowWarning", async () => {
const controller = new AbortController()
test("caps oversized header delays to the runtime timer limit", () => {
const error = apiError({ "retry-after-ms": "999999999999" })
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
})
const warnings: string[] = []
const originalWarn = process.emitWarning
process.emitWarning = (warning: string | Error) => {
warnings.push(typeof warning === "string" ? warning : warning.message)
}
test("policy updates retry status and increments attempts", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("session-retry-test")
const error = apiError({ "retry-after-ms": "0" })
const promise = SessionRetry.sleep(2_560_914_000, controller.signal)
controller.abort()
await Effect.runPromise(
Effect.gen(function* () {
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
sessionID,
parse: (err) => err as MessageV2.APIError,
}),
)
yield* step(error)
yield* step(error)
}),
)
try {
await promise
} catch {}
process.emitWarning = originalWarn
expect(warnings.some((w) => w.includes("TimeoutOverflowWarning"))).toBe(false)
expect(await SessionStatus.get(sessionID)).toMatchObject({
type: "retry",
attempt: 2,
message: "boom",
})
},
})
})
})
@@ -363,20 +363,25 @@ describe("structured-output.createStructuredOutputTool", () => {
expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string")
})
test("toModelOutput returns text value", () => {
test("toModelOutput returns text value", async () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" },
onSuccess: () => {},
})
expect(tool.toModelOutput).toBeDefined()
const modelOutput = tool.toModelOutput!({
output: "Test output",
title: "Test",
metadata: { valid: true },
})
const modelOutput = await Promise.resolve(
tool.toModelOutput!({
toolCallId: "test-call-id",
input: {},
output: {
output: "Test output",
},
}),
)
expect(modelOutput.type).toBe("text")
if (modelOutput.type !== "text") throw new Error("expected text model output")
expect(modelOutput.value).toBe("Test output")
})
+2 -1
View File
@@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
export type PluginModule = {
id?: string
server?: Plugin
server: Plugin
tui?: never
}
type Rule = {
+7 -3
View File
@@ -15,7 +15,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
import type { JSX, SolidPlugin } from "@opentui/solid"
import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js"
import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core"
@@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
description?: () => JSX.Element
placeholder?: string
value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -414,6 +416,8 @@ export type TuiPluginApi = {
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
export type TuiPluginModule = PluginModule & {
tui?: TuiPlugin
export type TuiPluginModule = {
id?: string
tui: TuiPlugin
server?: never
}
@@ -1 +0,0 @@
BlexMonoNerdFontMono-Bold.woff2
@@ -1 +0,0 @@
BlexMonoNerdFontMono-Medium.woff2
@@ -1 +0,0 @@
BlexMonoNerdFontMono-Regular.woff2
Binary file not shown.

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