Просмотр исходного кода

Merge branch 'dev' into sqlite2

Dax Raad 2 месяцев назад
Родитель
Сommit
db908deee5
100 измененных файлов с 2361 добавлено и 659 удалено
  1. 1 0
      .github/actions/setup-git-committer/action.yml
  2. 4 0
      .github/pull_request_template.md
  3. 3 9
      .github/workflows/beta.yml
  4. 0 2
      .github/workflows/generate.yml
  5. 92 79
      .github/workflows/nix-hashes.yml
  6. 1 1
      .github/workflows/publish.yml
  7. 3 0
      .github/workflows/test.yml
  8. 49 47
      bun.lock
  9. 5 18
      flake.nix
  10. 2 0
      infra/console.ts
  11. 4 4
      nix/hashes.json
  12. 4 7
      nix/node_modules.nix
  13. 1 1
      package.json
  14. 271 0
      packages/app/e2e/actions.ts
  15. 2 2
      packages/app/e2e/app/home.spec.ts
  16. 3 2
      packages/app/e2e/app/navigation.spec.ts
  17. 11 0
      packages/app/e2e/app/palette.spec.ts
  18. 11 23
      packages/app/e2e/app/server-default.spec.ts
  19. 16 0
      packages/app/e2e/app/session.spec.ts
  20. 42 0
      packages/app/e2e/app/titlebar-history.spec.ts
  21. 4 9
      packages/app/e2e/files/file-open.spec.ts
  22. 1 1
      packages/app/e2e/files/file-tree.spec.ts
  23. 4 13
      packages/app/e2e/files/file-viewer.spec.ts
  24. 14 49
      packages/app/e2e/fixtures.ts
  25. 4 5
      packages/app/e2e/models/model-picker.spec.ts
  26. 5 30
      packages/app/e2e/models/models-visibility.spec.ts
  27. 0 15
      packages/app/e2e/palette.spec.ts
  28. 52 0
      packages/app/e2e/projects/project-edit.spec.ts
  29. 70 0
      packages/app/e2e/projects/projects-close.spec.ts
  30. 34 0
      packages/app/e2e/projects/projects-switch.spec.ts
  31. 8 13
      packages/app/e2e/prompt/context.spec.ts
  32. 2 2
      packages/app/e2e/prompt/prompt-mention.spec.ts
  33. 2 2
      packages/app/e2e/prompt/prompt-slash-open.spec.ts
  34. 3 7
      packages/app/e2e/prompt/prompt.spec.ts
  35. 35 0
      packages/app/e2e/selectors.ts
  36. 0 21
      packages/app/e2e/session.spec.ts
  37. 115 0
      packages/app/e2e/session/session.spec.ts
  38. 0 44
      packages/app/e2e/settings.spec.ts
  39. 28 0
      packages/app/e2e/settings/settings-language.spec.ts
  40. 5 31
      packages/app/e2e/settings/settings-providers.spec.ts
  41. 14 0
      packages/app/e2e/settings/settings.spec.ts
  42. 0 21
      packages/app/e2e/sidebar.spec.ts
  43. 5 35
      packages/app/e2e/sidebar/sidebar-session-links.spec.ts
  44. 14 0
      packages/app/e2e/sidebar/sidebar.spec.ts
  45. 3 2
      packages/app/e2e/terminal/terminal-init.spec.ts
  46. 3 2
      packages/app/e2e/terminal/terminal.spec.ts
  47. 1 1
      packages/app/e2e/thinking-level.spec.ts
  48. 0 52
      packages/app/e2e/titlebar-history.spec.ts
  49. 1 1
      packages/app/e2e/tsconfig.json
  50. 0 4
      packages/app/e2e/utils.ts
  51. 1 1
      packages/app/package.json
  52. 1 1
      packages/app/script/e2e-local.ts
  53. 1 1
      packages/app/src/components/session/session-header.tsx
  54. 1 0
      packages/app/src/components/settings-general.tsx
  55. 13 1
      packages/app/src/pages/layout.tsx
  56. 1 1
      packages/console/app/package.json
  57. 1 1
      packages/console/core/package.json
  58. 6 5
      packages/console/core/script/promote-models.ts
  59. 6 5
      packages/console/core/script/pull-models.ts
  60. 9 7
      packages/console/core/script/update-models.ts
  61. 3 1
      packages/console/core/src/model.ts
  62. 8 0
      packages/console/core/sst-env.d.ts
  63. 1 1
      packages/console/function/package.json
  64. 8 0
      packages/console/function/sst-env.d.ts
  65. 1 1
      packages/console/mail/package.json
  66. 8 0
      packages/console/resource/sst-env.d.ts
  67. 1 1
      packages/desktop/package.json
  68. 1 1
      packages/enterprise/package.json
  69. 8 0
      packages/enterprise/sst-env.d.ts
  70. 6 6
      packages/extensions/zed/extension.toml
  71. 1 1
      packages/function/package.json
  72. 8 0
      packages/function/sst-env.d.ts
  73. 12 12
      packages/opencode/package.json
  74. 2 2
      packages/opencode/script/build.ts
  75. 0 4
      packages/opencode/script/publish.ts
  76. 1 0
      packages/opencode/src/cli/cmd/acp.ts
  77. 2 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  78. 17 4
      packages/opencode/src/cli/cmd/tui/attach.ts
  79. 2 1
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  80. 1 1
      packages/opencode/src/cli/cmd/tui/component/tips.tsx
  81. 8 1
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  82. 18 2
      packages/opencode/src/command/index.ts
  83. 0 23
      packages/opencode/src/config/config.ts
  84. 3 1
      packages/opencode/src/env/index.ts
  85. 3 3
      packages/opencode/src/file/ripgrep.ts
  86. 13 1
      packages/opencode/src/flag/flag.ts
  87. 1 1
      packages/opencode/src/provider/models.ts
  88. 13 12
      packages/opencode/src/provider/provider.ts
  89. 0 0
      packages/opencode/src/provider/sdk/copilot/README.md
  90. 169 0
      packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
  91. 15 0
      packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts
  92. 17 0
      packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts
  93. 64 0
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts
  94. 765 0
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
  95. 28 0
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts
  96. 44 0
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts
  97. 87 0
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts
  98. 1 1
      packages/opencode/src/provider/sdk/copilot/copilot-provider.ts
  99. 2 0
      packages/opencode/src/provider/sdk/copilot/index.ts
  100. 27 0
      packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts

+ 1 - 0
.github/actions/setup-git-committer/action.yml

@@ -23,6 +23,7 @@ runs:
       with:
       with:
         app-id: ${{ inputs.opencode-app-id }}
         app-id: ${{ inputs.opencode-app-id }}
         private-key: ${{ inputs.opencode-app-secret }}
         private-key: ${{ inputs.opencode-app-secret }}
+        owner: ${{ github.repository_owner }}
 
 
     - name: Configure git user
     - name: Configure git user
       run: |
       run: |

+ 4 - 0
.github/pull_request_template.md

@@ -1,3 +1,7 @@
 ### What does this PR do?
 ### What does this PR do?
 
 
+Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr.
+
+**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
+
 ### How did you verify your code works?
 ### How did you verify your code works?

+ 3 - 9
.github/workflows/beta.yml

@@ -1,21 +1,15 @@
 name: beta
 name: beta
 
 
 on:
 on:
-  push:
-    branches: [dev]
-  pull_request:
-    types: [opened, synchronize, labeled, unlabeled]
+  workflow_dispatch:
+  schedule:
+    - cron: "0 * * * *"
 
 
 jobs:
 jobs:
   sync:
   sync:
-    if: |
-      github.event_name == 'push' || 
-      (github.event_name == 'pull_request' && 
-       contains(github.event.pull_request.labels.*.name, 'contributor'))
     runs-on: blacksmith-4vcpu-ubuntu-2404
     runs-on: blacksmith-4vcpu-ubuntu-2404
     permissions:
     permissions:
       contents: write
       contents: write
-      pull-requests: write
     steps:
     steps:
       - name: Checkout repository
       - name: Checkout repository
         uses: actions/checkout@v4
         uses: actions/checkout@v4

+ 0 - 2
.github/workflows/generate.yml

@@ -4,8 +4,6 @@ on:
   push:
   push:
     branches:
     branches:
       - dev
       - dev
-  pull_request:
-  workflow_dispatch:
 
 
 jobs:
 jobs:
   generate:
   generate:

+ 92 - 79
.github/workflows/nix-hashes.yml

@@ -21,11 +21,68 @@ on:
       - ".github/workflows/nix-hashes.yml"
       - ".github/workflows/nix-hashes.yml"
 
 
 jobs:
 jobs:
-  nix-hashes:
-    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+  # Native runners required: bun install cross-compilation flags (--os/--cpu)
+  # do not produce byte-identical node_modules as native installs.
+  compute-hash:
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - system: x86_64-linux
+            runner: blacksmith-4vcpu-ubuntu-2404
+          - system: aarch64-linux
+            runner: blacksmith-4vcpu-ubuntu-2404-arm
+          - system: x86_64-darwin
+            runner: macos-15-intel
+          - system: aarch64-darwin
+            runner: macos-latest
+    runs-on: ${{ matrix.runner }}
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v6
+
+      - name: Setup Nix
+        uses: nixbuild/nix-quick-install-action@v34
+
+      - name: Compute node_modules hash
+        id: hash
+        env:
+          SYSTEM: ${{ matrix.system }}
+        run: |
+          set -euo pipefail
+
+          BUILD_LOG=$(mktemp)
+          trap 'rm -f "$BUILD_LOG"' EXIT
+
+          # Build with fakeHash to trigger hash mismatch and reveal correct hash
+          nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
+
+          HASH="$(grep -E 'got:\s+sha256-' "$BUILD_LOG" | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)"
+          if [ -z "$HASH" ]; then
+            HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)"
+          fi
+
+          if [ -z "$HASH" ]; then
+            echo "::error::Failed to compute hash for ${SYSTEM}"
+            cat "$BUILD_LOG"
+            exit 1
+          fi
+
+          echo "$HASH" > hash.txt
+          echo "Computed hash for ${SYSTEM}: $HASH"
+
+      - name: Upload hash
+        uses: actions/upload-artifact@v4
+        with:
+          name: hash-${{ matrix.system }}
+          path: hash.txt
+          retention-days: 1
+
+  update-hashes:
+    needs: compute-hash
+    if: github.event_name != 'pull_request'
     runs-on: blacksmith-4vcpu-ubuntu-2404
     runs-on: blacksmith-4vcpu-ubuntu-2404
-    env:
-      TITLE: node_modules hashes
 
 
     steps:
     steps:
       - name: Checkout repository
       - name: Checkout repository
@@ -33,108 +90,64 @@ jobs:
         with:
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
           token: ${{ secrets.GITHUB_TOKEN }}
           fetch-depth: 0
           fetch-depth: 0
-          ref: ${{ github.head_ref || github.ref_name }}
-          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+          ref: ${{ github.ref_name }}
 
 
       - name: Setup git committer
       - name: Setup git committer
-        id: committer
         uses: ./.github/actions/setup-git-committer
         uses: ./.github/actions/setup-git-committer
         with:
         with:
           opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
           opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
           opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
           opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
 
 
-      - name: Setup Nix
-        uses: nixbuild/nix-quick-install-action@v34
-
       - name: Pull latest changes
       - name: Pull latest changes
-        env:
-          TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
         run: |
         run: |
-          BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
-          git pull --rebase --autostash origin "$BRANCH"
+          git pull --rebase --autostash origin "$GITHUB_REF_NAME"
 
 
-      - name: Compute all node_modules hashes
+      - name: Download hash artifacts
+        uses: actions/download-artifact@v4
+        with:
+          path: hashes
+          pattern: hash-*
+
+      - name: Update hashes.json
         run: |
         run: |
           set -euo pipefail
           set -euo pipefail
 
 
           HASH_FILE="nix/hashes.json"
           HASH_FILE="nix/hashes.json"
-          SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
-
-          if [ ! -f "$HASH_FILE" ]; then
-            mkdir -p "$(dirname "$HASH_FILE")"
-            echo '{"nodeModules":{}}' > "$HASH_FILE"
-          fi
-
-          for SYSTEM in $SYSTEMS; do
-            echo "Computing hash for ${SYSTEM}..."
-            BUILD_LOG=$(mktemp)
-            trap 'rm -f "$BUILD_LOG"' EXIT
 
 
-            # The updater derivations use fakeHash, so they will fail and reveal the correct hash
-            UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
-
-            nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
-
-            CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
-
-            if [ -z "$CORRECT_HASH" ]; then
-              CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
-            fi
-
-            if [ -z "$CORRECT_HASH" ]; then
-              echo "Failed to determine correct node_modules hash for ${SYSTEM}."
-              cat "$BUILD_LOG"
-              exit 1
+          [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
+
+          for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
+            FILE="hashes/hash-${SYSTEM}/hash.txt"
+            if [ -f "$FILE" ]; then
+              HASH="$(tr -d '[:space:]' < "$FILE")"
+              echo "${SYSTEM}: ${HASH}"
+              jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
+              mv tmp.json "$HASH_FILE"
+            else
+              echo "::warning::Missing hash for ${SYSTEM}"
             fi
             fi
-
-            echo "  ${SYSTEM}: ${CORRECT_HASH}"
-            jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
-              '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
-            mv "${HASH_FILE}.tmp" "$HASH_FILE"
           done
           done
 
 
-          echo "All hashes computed:"
           cat "$HASH_FILE"
           cat "$HASH_FILE"
 
 
-      - name: Commit ${{ env.TITLE }} changes
-        env:
-          TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+      - name: Commit changes
         run: |
         run: |
           set -euo pipefail
           set -euo pipefail
 
 
           HASH_FILE="nix/hashes.json"
           HASH_FILE="nix/hashes.json"
-          echo "Checking for changes..."
-
-          summarize() {
-            local status="$1"
-            {
-              echo "### Nix $TITLE"
-              echo ""
-              echo "- ref: ${GITHUB_REF_NAME}"
-              echo "- status: ${status}"
-            } >> "$GITHUB_STEP_SUMMARY"
-            if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
-              echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
-            fi
-            echo "" >> "$GITHUB_STEP_SUMMARY"
-          }
-
-          FILES=("$HASH_FILE")
-          STATUS="$(git status --short -- "${FILES[@]}" || true)"
-          if [ -z "$STATUS" ]; then
-            echo "No changes detected."
-            summarize "no changes"
+
+          if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
+            echo "No changes to commit"
+            echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+            echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
             exit 0
             exit 0
           fi
           fi
 
 
-          echo "Changes detected:"
-          echo "$STATUS"
-          git add "${FILES[@]}"
+          git add "$HASH_FILE"
           git commit -m "chore: update nix node_modules hashes"
           git commit -m "chore: update nix node_modules hashes"
 
 
-          BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
-          git pull --rebase --autostash origin "$BRANCH"
-          git push origin HEAD:"$BRANCH"
-          echo "Changes pushed successfully"
+          git pull --rebase --autostash origin "$GITHUB_REF_NAME"
+          git push origin HEAD:"$GITHUB_REF_NAME"
 
 
-          summarize "committed $(git rev-parse --short HEAD)"
+          echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+          echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"

+ 1 - 1
.github/workflows/publish.yml

@@ -103,7 +103,7 @@ jobs:
             target: x86_64-pc-windows-msvc
             target: x86_64-pc-windows-msvc
           - host: blacksmith-4vcpu-ubuntu-2404
           - host: blacksmith-4vcpu-ubuntu-2404
             target: x86_64-unknown-linux-gnu
             target: x86_64-unknown-linux-gnu
-          - host: blacksmith-4vcpu-ubuntu-2404-arm
+          - host: blacksmith-8vcpu-ubuntu-2404-arm
             target: aarch64-unknown-linux-gnu
             target: aarch64-unknown-linux-gnu
     runs-on: ${{ matrix.settings.host }}
     runs-on: ${{ matrix.settings.host }}
     steps:
     steps:

+ 3 - 0
.github/workflows/test.yml

@@ -1,6 +1,9 @@
 name: test
 name: test
 
 
 on:
 on:
+  push:
+    branches:
+      - dev
   pull_request:
   pull_request:
   workflow_dispatch:
   workflow_dispatch:
 jobs:
 jobs:

+ 49 - 47
bun.lock

@@ -23,7 +23,7 @@
     },
     },
     "packages/app": {
     "packages/app": {
       "name": "@opencode-ai/app",
       "name": "@opencode-ai/app",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
     },
     },
     "packages/console/app": {
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "name": "@opencode-ai/console-app",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
     },
     },
     "packages/console/core": {
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
       "name": "@opencode-ai/console-core",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
         "@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
     },
     },
     "packages/console/function": {
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
       "name": "@opencode-ai/console-function",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
         "@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
     },
     },
     "packages/console/mail": {
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
       "name": "@opencode-ai/console-mail",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
         "@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
     },
     },
     "packages/desktop": {
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
       "name": "@opencode-ai/desktop",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -213,7 +213,7 @@
     },
     },
     "packages/enterprise": {
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
       "name": "@opencode-ai/enterprise",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -242,7 +242,7 @@
     },
     },
     "packages/function": {
     "packages/function": {
       "name": "@opencode-ai/function",
       "name": "@opencode-ai/function",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
@@ -258,7 +258,7 @@
     },
     },
     "packages/opencode": {
     "packages/opencode": {
       "name": "opencode",
       "name": "opencode",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -266,25 +266,25 @@
         "@actions/core": "1.11.1",
         "@actions/core": "1.11.1",
         "@actions/github": "6.0.1",
         "@actions/github": "6.0.1",
         "@agentclientprotocol/sdk": "0.13.0",
         "@agentclientprotocol/sdk": "0.13.0",
-        "@ai-sdk/amazon-bedrock": "3.0.73",
-        "@ai-sdk/anthropic": "2.0.57",
+        "@ai-sdk/amazon-bedrock": "3.0.74",
+        "@ai-sdk/anthropic": "2.0.58",
         "@ai-sdk/azure": "2.0.91",
         "@ai-sdk/azure": "2.0.91",
-        "@ai-sdk/cerebras": "1.0.34",
+        "@ai-sdk/cerebras": "1.0.36",
         "@ai-sdk/cohere": "2.0.22",
         "@ai-sdk/cohere": "2.0.22",
-        "@ai-sdk/deepinfra": "1.0.31",
-        "@ai-sdk/gateway": "2.0.25",
+        "@ai-sdk/deepinfra": "1.0.33",
+        "@ai-sdk/gateway": "2.0.30",
         "@ai-sdk/google": "2.0.52",
         "@ai-sdk/google": "2.0.52",
-        "@ai-sdk/google-vertex": "3.0.97",
+        "@ai-sdk/google-vertex": "3.0.98",
         "@ai-sdk/groq": "2.0.34",
         "@ai-sdk/groq": "2.0.34",
         "@ai-sdk/mistral": "2.0.27",
         "@ai-sdk/mistral": "2.0.27",
         "@ai-sdk/openai": "2.0.89",
         "@ai-sdk/openai": "2.0.89",
-        "@ai-sdk/openai-compatible": "1.0.30",
+        "@ai-sdk/openai-compatible": "1.0.32",
         "@ai-sdk/perplexity": "2.0.23",
         "@ai-sdk/perplexity": "2.0.23",
         "@ai-sdk/provider": "2.0.1",
         "@ai-sdk/provider": "2.0.1",
         "@ai-sdk/provider-utils": "3.0.20",
         "@ai-sdk/provider-utils": "3.0.20",
-        "@ai-sdk/togetherai": "1.0.31",
-        "@ai-sdk/vercel": "1.0.31",
-        "@ai-sdk/xai": "2.0.51",
+        "@ai-sdk/togetherai": "1.0.34",
+        "@ai-sdk/vercel": "1.0.33",
+        "@ai-sdk/xai": "2.0.56",
         "@clack/prompts": "1.0.0-alpha.1",
         "@clack/prompts": "1.0.0-alpha.1",
         "@gitlab/gitlab-ai-provider": "3.3.1",
         "@gitlab/gitlab-ai-provider": "3.3.1",
         "@hono/standard-validator": "0.1.5",
         "@hono/standard-validator": "0.1.5",
@@ -297,7 +297,7 @@
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@opencode-ai/util": "workspace:*",
-        "@openrouter/ai-sdk-provider": "1.5.2",
+        "@openrouter/ai-sdk-provider": "1.5.4",
         "@opentui/core": "0.1.75",
         "@opentui/core": "0.1.75",
         "@opentui/solid": "0.1.75",
         "@opentui/solid": "0.1.75",
         "@parcel/watcher": "2.5.1",
         "@parcel/watcher": "2.5.1",
@@ -365,7 +365,7 @@
     },
     },
     "packages/plugin": {
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
       "name": "@opencode-ai/plugin",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
         "zod": "catalog:",
@@ -385,7 +385,7 @@
     },
     },
     "packages/sdk/js": {
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "name": "@opencode-ai/sdk",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "devDependencies": {
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
         "@tsconfig/node22": "catalog:",
@@ -396,7 +396,7 @@
     },
     },
     "packages/slack": {
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "name": "@opencode-ai/slack",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
         "@slack/bolt": "^3.17.1",
@@ -409,7 +409,7 @@
     },
     },
     "packages/ui": {
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "name": "@opencode-ai/ui",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -451,7 +451,7 @@
     },
     },
     "packages/util": {
     "packages/util": {
       "name": "@opencode-ai/util",
       "name": "@opencode-ai/util",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "zod": "catalog:",
         "zod": "catalog:",
       },
       },
@@ -462,7 +462,7 @@
     },
     },
     "packages/web": {
     "packages/web": {
       "name": "@opencode-ai/web",
       "name": "@opencode-ai/web",
-      "version": "1.1.45",
+      "version": "1.1.48",
       "dependencies": {
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
         "@astrojs/markdown-remark": "6.3.1",
@@ -524,7 +524,7 @@
     "@types/node": "22.13.9",
     "@types/node": "22.13.9",
     "@types/semver": "7.7.1",
     "@types/semver": "7.7.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
-    "ai": "5.0.119",
+    "ai": "5.0.124",
     "diff": "8.0.2",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
     "dompurify": "3.3.1",
     "drizzle-kit": "1.0.0-beta.12-a5629fb",
     "drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -564,23 +564,23 @@
 
 
     "@agentclientprotocol/sdk": ["@agentclientprotocol/[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
     "@agentclientprotocol/sdk": ["@agentclientprotocol/[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
 
 
-    "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]3", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@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-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
+    "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]4", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@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-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
 
 
     "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "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/[email protected]", "", { "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/azure": ["@ai-sdk/[email protected]", "", { "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/[email protected]", "", { "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/cerebras": ["@ai-sdk/[email protected]4", "", { "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-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
+    "@ai-sdk/cerebras": ["@ai-sdk/[email protected]6", "", { "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/cohere": ["@ai-sdk/[email protected]", "", { "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/[email protected]", "", { "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/deepinfra": ["@ai-sdk/[email protected]1", "", { "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-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
+    "@ai-sdk/deepinfra": ["@ai-sdk/[email protected]3", "", { "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-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
 
 
-    "@ai-sdk/gateway": ["@ai-sdk/[email protected].25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
+    "@ai-sdk/gateway": ["@ai-sdk/[email protected].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/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
     "@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
 
 
-    "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]7", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
+    "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]8", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
 
 
     "@ai-sdk/groq": ["@ai-sdk/[email protected]", "", { "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/[email protected]", "", { "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=="],
 
 
@@ -596,11 +596,11 @@
 
 
     "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "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": ["@ai-sdk/[email protected]", "", { "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/[email protected]1", "", { "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-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
+    "@ai-sdk/togetherai": ["@ai-sdk/[email protected]4", "", { "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/vercel": ["@ai-sdk/[email protected]1", "", { "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-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
+    "@ai-sdk/vercel": ["@ai-sdk/[email protected]3", "", { "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/xai": ["@ai-sdk/[email protected]1", "", { "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/[email protected]6", "", { "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-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
 
 
     "@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
     "@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
 
 
@@ -1240,7 +1240,7 @@
 
 
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
 
 
-    "@openrouter/ai-sdk-provider": ["@openrouter/[email protected].2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
+    "@openrouter/ai-sdk-provider": ["@openrouter/[email protected].4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
 
 
     "@openrouter/sdk": ["@openrouter/[email protected]", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
     "@openrouter/sdk": ["@openrouter/[email protected]", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
 
 
@@ -1932,7 +1932,7 @@
 
 
     "@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
     "@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
 
 
-    "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
+    "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
 
 
     "@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
     "@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
 
 
@@ -1970,7 +1970,7 @@
 
 
     "agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
     "agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
 
 
-    "ai": ["[email protected]19", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@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-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
+    "ai": ["[email protected]24", "", { "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=="],
 
 
     "ajv": ["[email protected]", "", { "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-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
     "ajv": ["[email protected]", "", { "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-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
 
 
@@ -4008,7 +4008,9 @@
 
 
     "@actions/http-client/undici": ["[email protected]", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
     "@actions/http-client/undici": ["[email protected]", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
 
 
-    "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+    "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
+
+    "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
 
 
     "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
     "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
 
 
@@ -4016,11 +4018,11 @@
 
 
     "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "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/azure/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "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/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]0", "", { "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/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]0", "", { "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/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]7", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+    "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]8", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
 
 
     "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
     "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
 
 
@@ -4030,11 +4032,11 @@
 
 
     "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "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-utils": ["@ai-sdk/[email protected]", "", { "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/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]0", "", { "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/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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/openai-compatible": ["@ai-sdk/[email protected]0", "", { "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/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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/xai/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]0", "", { "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/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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=="],
 
 
     "@astrojs/cloudflare/vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
     "@astrojs/cloudflare/vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
 
 
@@ -4414,11 +4416,11 @@
 
 
     "nypm/tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
     "nypm/tinyexec": ["[email protected]", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
 
 
-    "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]7", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+    "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]8", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
 
 
     "opencode/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "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": ["@ai-sdk/[email protected]", "", { "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/[email protected]0", "", { "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=="],
+    "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]2", "", { "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=="],
 
 
     "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
     "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
 
 

+ 5 - 18
flake.nix

@@ -42,28 +42,15 @@
           desktop = pkgs.callPackage ./nix/desktop.nix {
           desktop = pkgs.callPackage ./nix/desktop.nix {
             inherit opencode;
             inherit opencode;
           };
           };
-          # nixpkgs cpu naming to bun cpu naming
-          cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
-          # matrix of node_modules builds - these will always fail due to fakeHash usage
-          # but allow computation of the correct hash from any build machine for any cpu/os
-          # see the update-nix-hashes workflow for usage
-          moduleUpdaters = pkgs.lib.listToAttrs (
-            pkgs.lib.concatMap (cpu:
-              map (os: {
-                name = "${cpu}-${os}_node_modules";
-                value = node_modules.override {
-                  bunCpu = cpuMap.${cpu};
-                  bunOs = os;
-                  hash = pkgs.lib.fakeHash;
-                };
-              }) [ "linux" "darwin" ]
-            ) [ "x86_64" "aarch64" ]
-          );
         in
         in
         {
         {
           default = opencode;
           default = opencode;
           inherit opencode desktop;
           inherit opencode desktop;
-        } // moduleUpdaters
+          # Updater derivation with fakeHash - build fails and reveals correct hash
+          node_modules_updater = node_modules.override {
+            hash = pkgs.lib.fakeHash;
+          };
+        }
       );
       );
     };
     };
 }
 }

+ 2 - 0
infra/console.ts

@@ -133,6 +133,8 @@ const ZEN_MODELS = [
   new sst.Secret("ZEN_MODELS6"),
   new sst.Secret("ZEN_MODELS6"),
   new sst.Secret("ZEN_MODELS7"),
   new sst.Secret("ZEN_MODELS7"),
   new sst.Secret("ZEN_MODELS8"),
   new sst.Secret("ZEN_MODELS8"),
+  new sst.Secret("ZEN_MODELS9"),
+  new sst.Secret("ZEN_MODELS10"),
 ]
 ]
 const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
 const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
 const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
 const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "nodeModules": {
-    "x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
-    "aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
-    "aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
-    "x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
+    "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=",
+    "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
+    "aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=",
+    "x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY="
   }
   }
 }
 }

+ 4 - 7
nix/node_modules.nix

@@ -2,8 +2,6 @@
   lib,
   lib,
   stdenvNoCC,
   stdenvNoCC,
   bun,
   bun,
-  bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
-  bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
   rev ? "dirty",
   rev ? "dirty",
   hash ?
   hash ?
     (lib.pipe ./hashes.json [
     (lib.pipe ./hashes.json [
@@ -16,6 +14,9 @@ let
     builtins.readFile
     builtins.readFile
     builtins.fromJSON
     builtins.fromJSON
   ];
   ];
+  platform = stdenvNoCC.hostPlatform;
+  bunCpu = if platform.isAarch64 then "arm64" else "x64";
+  bunOs = if platform.isLinux then "linux" else "darwin";
 in
 in
 stdenvNoCC.mkDerivation {
 stdenvNoCC.mkDerivation {
   pname = "opencode-node_modules";
   pname = "opencode-node_modules";
@@ -39,9 +40,7 @@ stdenvNoCC.mkDerivation {
     "SOCKS_SERVER"
     "SOCKS_SERVER"
   ];
   ];
 
 
-  nativeBuildInputs = [
-    bun
-  ];
+  nativeBuildInputs = [ bun ];
 
 
   dontConfigure = true;
   dontConfigure = true;
 
 
@@ -63,10 +62,8 @@ stdenvNoCC.mkDerivation {
 
 
   installPhase = ''
   installPhase = ''
     runHook preInstall
     runHook preInstall
-
     mkdir -p $out
     mkdir -p $out
     find . -type d -name node_modules -exec cp -R --parents {} $out \;
     find . -type d -name node_modules -exec cp -R --parents {} $out \;
-
     runHook postInstall
     runHook postInstall
   '';
   '';
 
 

+ 1 - 1
package.json

@@ -40,7 +40,7 @@
       "dompurify": "3.3.1",
       "dompurify": "3.3.1",
       "drizzle-kit": "1.0.0-beta.12-a5629fb",
       "drizzle-kit": "1.0.0-beta.12-a5629fb",
       "drizzle-orm": "1.0.0-beta.12-a5629fb",
       "drizzle-orm": "1.0.0-beta.12-a5629fb",
-      "ai": "5.0.119",
+      "ai": "5.0.124",
       "hono": "4.10.7",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",
       "fuzzysort": "3.1.0",

+ 271 - 0
packages/app/e2e/actions.ts

@@ -0,0 +1,271 @@
+import { expect, type Locator, type Page } from "@playwright/test"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { execSync } from "node:child_process"
+import { modKey, serverUrl } from "./utils"
+import {
+  sessionItemSelector,
+  dropdownMenuTriggerSelector,
+  dropdownMenuContentSelector,
+  titlebarRightSelector,
+  popoverBodySelector,
+  listItemSelector,
+  listItemKeySelector,
+  listItemKeyStartsWithSelector,
+} from "./selectors"
+import type { createSdk } from "./utils"
+
+export async function defocus(page: Page) {
+  await page.mouse.click(5, 5)
+}
+
+export async function openPalette(page: Page) {
+  await defocus(page)
+  await page.keyboard.press(`${modKey}+P`)
+
+  const dialog = page.getByRole("dialog")
+  await expect(dialog).toBeVisible()
+  await expect(dialog.getByRole("textbox").first()).toBeVisible()
+  return dialog
+}
+
+export async function closeDialog(page: Page, dialog: Locator) {
+  await page.keyboard.press("Escape")
+  const closed = await dialog
+    .waitFor({ state: "detached", timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (closed) return
+
+  await page.keyboard.press("Escape")
+  const closedSecond = await dialog
+    .waitFor({ state: "detached", timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (closedSecond) return
+
+  await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
+  await expect(dialog).toHaveCount(0)
+}
+
+export async function isSidebarClosed(page: Page) {
+  const main = page.locator("main")
+  const classes = (await main.getAttribute("class")) ?? ""
+  return classes.includes("xl:border-l")
+}
+
+export async function toggleSidebar(page: Page) {
+  await defocus(page)
+  await page.keyboard.press(`${modKey}+B`)
+}
+
+export async function openSidebar(page: Page) {
+  if (!(await isSidebarClosed(page))) return
+  await toggleSidebar(page)
+  await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+}
+
+export async function closeSidebar(page: Page) {
+  if (await isSidebarClosed(page)) return
+  await toggleSidebar(page)
+  await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+}
+
+export async function openSettings(page: Page) {
+  await defocus(page)
+
+  const dialog = page.getByRole("dialog")
+  await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
+
+  const opened = await dialog
+    .waitFor({ state: "visible", timeout: 3000 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (opened) return dialog
+
+  await page.getByRole("button", { name: "Settings" }).first().click()
+  await expect(dialog).toBeVisible()
+  return dialog
+}
+
+export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
+  await page.addInitScript(
+    (args: { directory: string; serverUrl: string; extra: string[] }) => {
+      const key = "opencode.global.dat:server"
+      const raw = localStorage.getItem(key)
+      const parsed = (() => {
+        if (!raw) return undefined
+        try {
+          return JSON.parse(raw) as unknown
+        } catch {
+          return undefined
+        }
+      })()
+
+      const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
+      const list = Array.isArray(store.list) ? store.list : []
+      const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
+      const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
+      const nextProjects = { ...(projects as Record<string, unknown>) }
+
+      const add = (origin: string, directory: string) => {
+        const current = nextProjects[origin]
+        const items = Array.isArray(current) ? current : []
+        const existing = items.filter(
+          (p): p is { worktree: string; expanded?: boolean } =>
+            !!p &&
+            typeof p === "object" &&
+            "worktree" in p &&
+            typeof (p as { worktree?: unknown }).worktree === "string",
+        )
+
+        if (existing.some((p) => p.worktree === directory)) return
+        nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
+      }
+
+      const directories = [args.directory, ...args.extra]
+      for (const directory of directories) {
+        add("local", directory)
+        add(args.serverUrl, directory)
+      }
+
+      localStorage.setItem(
+        key,
+        JSON.stringify({
+          list,
+          projects: nextProjects,
+          lastProject,
+        }),
+      )
+    },
+    { directory: input.directory, serverUrl, extra: input.extra ?? [] },
+  )
+}
+
+export async function createTestProject() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
+
+  await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
+
+  execSync("git init", { cwd: root, stdio: "ignore" })
+  execSync("git add -A", { cwd: root, stdio: "ignore" })
+  execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
+    cwd: root,
+    stdio: "ignore",
+  })
+
+  return root
+}
+
+export async function cleanupTestProject(directory: string) {
+  await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
+}
+
+export function sessionIDFromUrl(url: string) {
+  const match = /\/session\/([^/?#]+)/.exec(url)
+  return match?.[1]
+}
+
+export async function hoverSessionItem(page: Page, sessionID: string) {
+  const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
+  await expect(sessionEl).toBeVisible()
+  await sessionEl.hover()
+  return sessionEl
+}
+
+export async function openSessionMoreMenu(page: Page, sessionID: string) {
+  const sessionEl = await hoverSessionItem(page, sessionID)
+
+  const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
+  await expect(menuTrigger).toBeVisible()
+  await menuTrigger.click()
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  await expect(menu).toBeVisible()
+  return menu
+}
+
+export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
+  const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
+  await expect(item).toBeVisible()
+  await item.click({ force: options?.force })
+}
+
+export async function confirmDialog(page: Page, buttonName: string | RegExp) {
+  const dialog = page.getByRole("dialog").first()
+  await expect(dialog).toBeVisible()
+
+  const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
+  await expect(button).toBeVisible()
+  await button.click()
+}
+
+export async function openSharePopover(page: Page) {
+  const rightSection = page.locator(titlebarRightSelector)
+  const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
+  await expect(shareButton).toBeVisible()
+
+  const popoverBody = page
+    .locator(popoverBodySelector)
+    .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
+    .first()
+
+  const opened = await popoverBody
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (!opened) {
+    await shareButton.click()
+    await expect(popoverBody).toBeVisible()
+  }
+  return { rightSection, popoverBody }
+}
+
+export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
+  const button = page.getByRole("button").filter({ hasText: buttonName }).first()
+  await expect(button).toBeVisible()
+  await button.click()
+}
+
+export async function clickListItem(
+  container: Locator | Page,
+  filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
+): Promise<Locator> {
+  let item: Locator
+
+  if (typeof filter === "string" || filter instanceof RegExp) {
+    item = container.locator(listItemSelector).filter({ hasText: filter }).first()
+  } else if (filter.keyStartsWith) {
+    item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
+  } else if (filter.key) {
+    item = container.locator(listItemKeySelector(filter.key)).first()
+  } else if (filter.text) {
+    item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
+  } else {
+    throw new Error("Invalid filter provided to clickListItem")
+  }
+
+  await expect(item).toBeVisible()
+  await item.click()
+  return item
+}
+
+export async function withSession<T>(
+  sdk: ReturnType<typeof createSdk>,
+  title: string,
+  callback: (session: { id: string; title: string }) => Promise<T>,
+): Promise<T> {
+  const session = await sdk.session.create({ title }).then((r) => r.data)
+  if (!session?.id) throw new Error("Session create did not return an id")
+
+  try {
+    return await callback(session)
+  } finally {
+    await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+  }
+}

+ 2 - 2
packages/app/e2e/home.spec.ts → packages/app/e2e/app/home.spec.ts

@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { serverName } from "./utils"
+import { test, expect } from "../fixtures"
+import { serverName } from "../utils"
 
 
 test("home renders and shows core entrypoints", async ({ page }) => {
 test("home renders and shows core entrypoints", async ({ page }) => {
   await page.goto("/")
   await page.goto("/")

+ 3 - 2
packages/app/e2e/navigation.spec.ts → packages/app/e2e/app/navigation.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { dirPath, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { dirPath } from "../utils"
 
 
 test("project route redirects to /session", async ({ page, directory, slug }) => {
 test("project route redirects to /session", async ({ page, directory, slug }) => {
   await page.goto(dirPath(directory))
   await page.goto(dirPath(directory))

+ 11 - 0
packages/app/e2e/app/palette.spec.ts

@@ -0,0 +1,11 @@
+import { test, expect } from "../fixtures"
+import { openPalette } from "../actions"
+
+test("search palette opens and closes", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openPalette(page)
+
+  await page.keyboard.press("Escape")
+  await expect(dialog).toHaveCount(0)
+})

+ 11 - 23
packages/app/e2e/server-default.spec.ts → packages/app/e2e/app/server-default.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { serverName, serverUrl } from "./utils"
+import { test, expect } from "../fixtures"
+import { serverName, serverUrl } from "../utils"
+import { clickListItem, closeDialog, clickMenuItem } from "../actions"
 
 
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 
 
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
   const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
   const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
   await expect(row).toBeVisible()
   await expect(row).toBeVisible()
 
 
-  const menu = row.locator('[data-component="icon-button"]').last()
-  await menu.click()
-  await page.getByRole("menuitem", { name: "Set as default" }).click()
+  const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
+  await expect(menuTrigger).toBeVisible()
+  await menuTrigger.click({ force: true })
+
+  const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+  await expect(menu).toBeVisible()
+  await clickMenuItem(menu, /set as default/i)
 
 
   await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
   await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
   await expect(row.getByText("Default", { exact: true })).toBeVisible()
   await expect(row.getByText("Default", { exact: true })).toBeVisible()
 
 
-  await page.keyboard.press("Escape")
-  const closed = await dialog
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (!closed) {
-    await page.keyboard.press("Escape")
-    const closedSecond = await dialog
-      .waitFor({ state: "detached", timeout: 1500 })
-      .then(() => true)
-      .catch(() => false)
-
-    if (!closedSecond) {
-      await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
-      await expect(dialog).toHaveCount(0)
-    }
-  }
+  await closeDialog(page, dialog)
 
 
   await ensurePopoverOpen()
   await ensurePopoverOpen()
 
 

+ 16 - 0
packages/app/e2e/app/session.spec.ts

@@ -0,0 +1,16 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
+  const title = `e2e smoke ${Date.now()}`
+
+  await withSession(sdk, title, async (session) => {
+    await gotoSession(session.id)
+
+    const prompt = page.locator(promptSelector)
+    await prompt.click()
+    await page.keyboard.type("hello from e2e")
+    await expect(prompt).toContainText("hello from e2e")
+  })
+})

+ 42 - 0
packages/app/e2e/app/titlebar-history.spec.ts

@@ -0,0 +1,42 @@
+import { test, expect } from "../fixtures"
+import { openSidebar, withSession } from "../actions"
+import { promptSelector } from "../selectors"
+
+test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const stamp = Date.now()
+
+  await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
+    await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
+      await gotoSession(one.id)
+
+      await openSidebar(page)
+
+      const link = page.locator(`[data-session-id="${two.id}"] a`).first()
+      await expect(link).toBeVisible()
+      await link.scrollIntoViewIfNeeded()
+      await link.click()
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+
+      const back = page.getByRole("button", { name: "Back" })
+      const forward = page.getByRole("button", { name: "Forward" })
+
+      await expect(back).toBeVisible()
+      await expect(back).toBeEnabled()
+      await back.click()
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+
+      await expect(forward).toBeVisible()
+      await expect(forward).toBeEnabled()
+      await forward.click()
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})

+ 4 - 9
packages/app/e2e/file-open.spec.ts → packages/app/e2e/files/file-open.spec.ts

@@ -1,20 +1,15 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { openPalette, clickListItem } from "../actions"
 
 
 test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
 test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
-  await page.keyboard.press(`${modKey}+P`)
-
-  const dialog = page.getByRole("dialog")
-  await expect(dialog).toBeVisible()
+  const dialog = await openPalette(page)
 
 
   const input = dialog.getByRole("textbox").first()
   const input = dialog.getByRole("textbox").first()
   await input.fill("package.json")
   await input.fill("package.json")
 
 
-  const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
-  await expect(fileItem).toBeVisible()
-  await fileItem.click()
+  await clickListItem(dialog, { keyStartsWith: "file:" })
 
 
   await expect(dialog).toHaveCount(0)
   await expect(dialog).toHaveCount(0)
 
 

+ 1 - 1
packages/app/e2e/file-tree.spec.ts → packages/app/e2e/files/file-tree.spec.ts

@@ -1,4 +1,4 @@
-import { test, expect } from "./fixtures"
+import { test, expect } from "../fixtures"
 
 
 test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
 test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 4 - 13
packages/app/e2e/file-viewer.spec.ts → packages/app/e2e/files/file-viewer.spec.ts

@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { openPalette, clickListItem } from "../actions"
 
 
 test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
 test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
   const sep = process.platform === "win32" ? "\\" : "/"
   const sep = process.platform === "win32" ? "\\" : "/"
   const file = ["packages", "app", "package.json"].join(sep)
   const file = ["packages", "app", "package.json"].join(sep)
 
 
-  await page.keyboard.press(`${modKey}+P`)
-
-  const dialog = page.getByRole("dialog")
-  await expect(dialog).toBeVisible()
+  const dialog = await openPalette(page)
 
 
   const input = dialog.getByRole("textbox").first()
   const input = dialog.getByRole("textbox").first()
   await input.fill(file)
   await input.fill(file)
 
 
-  const fileItem = dialog
-    .locator(
-      '[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
-    )
-    .first()
-  await expect(fileItem).toBeVisible()
-  await fileItem.click()
+  await clickListItem(dialog, { text: /packages.*app.*package.json/ })
 
 
   await expect(dialog).toHaveCount(0)
   await expect(dialog).toHaveCount(0)
 
 

+ 14 - 49
packages/app/e2e/fixtures.ts

@@ -1,5 +1,7 @@
 import { test as base, expect } from "@playwright/test"
 import { test as base, expect } from "@playwright/test"
-import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
+import { seedProjects } from "./actions"
+import { promptSelector } from "./selectors"
+import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
 
 type TestFixtures = {
 type TestFixtures = {
   sdk: ReturnType<typeof createSdk>
   sdk: ReturnType<typeof createSdk>
@@ -29,54 +31,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     await use(createSdk(directory))
     await use(createSdk(directory))
   },
   },
   gotoSession: async ({ page, directory }, use) => {
   gotoSession: async ({ page, directory }, use) => {
-    await page.addInitScript(
-      (input: { directory: string; serverUrl: string }) => {
-        const key = "opencode.global.dat:server"
-        const raw = localStorage.getItem(key)
-        const parsed = (() => {
-          if (!raw) return undefined
-          try {
-            return JSON.parse(raw) as unknown
-          } catch {
-            return undefined
-          }
-        })()
-
-        const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
-        const list = Array.isArray(store.list) ? store.list : []
-        const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
-        const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
-        const nextProjects = { ...(projects as Record<string, unknown>) }
-
-        const add = (origin: string) => {
-          const current = nextProjects[origin]
-          const items = Array.isArray(current) ? current : []
-          const existing = items.filter(
-            (p): p is { worktree: string; expanded?: boolean } =>
-              !!p &&
-              typeof p === "object" &&
-              "worktree" in p &&
-              typeof (p as { worktree?: unknown }).worktree === "string",
-          )
-
-          if (existing.some((p) => p.worktree === input.directory)) return
-          nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
-        }
-
-        add("local")
-        add(input.serverUrl)
-
-        localStorage.setItem(
-          key,
-          JSON.stringify({
-            list,
-            projects: nextProjects,
-            lastProject,
-          }),
-        )
-      },
-      { directory, serverUrl },
-    )
+    await seedProjects(page, { directory })
+    await page.addInitScript(() => {
+      localStorage.setItem(
+        "opencode.global.dat:model",
+        JSON.stringify({
+          recent: [{ providerID: "opencode", modelID: "big-pickle" }],
+          user: [],
+          variant: {},
+        }),
+      )
+    })
 
 
     const gotoSession = async (sessionID?: string) => {
     const gotoSession = async (sessionID?: string) => {
       await page.goto(sessionPath(directory, sessionID))
       await page.goto(sessionPath(directory, sessionID))

+ 4 - 5
packages/app/e2e/model-picker.spec.ts → packages/app/e2e/models/model-picker.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { clickListItem } from "../actions"
 
 
 test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
 test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
 
 
   await input.fill(model)
   await input.fill(model)
 
 
-  const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
-  await expect(item).toBeVisible()
-  await item.click()
+  await clickListItem(dialog, { key })
 
 
   await expect(dialog).toHaveCount(0)
   await expect(dialog).toHaveCount(0)
 
 

+ 5 - 30
packages/app/e2e/models-visibility.spec.ts → packages/app/e2e/models/models-visibility.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings, clickListItem } from "../actions"
 
 
 test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
 test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
   await page.keyboard.press("Escape")
   await page.keyboard.press("Escape")
   await expect(picker).toHaveCount(0)
   await expect(picker).toHaveCount(0)
 
 
-  const settings = page.getByRole("dialog")
-
-  await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-  const opened = await settings
-    .waitFor({ state: "visible", timeout: 3000 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (!opened) {
-    await page.getByRole("button", { name: "Settings" }).first().click()
-    await expect(settings).toBeVisible()
-  }
+  const settings = await openSettings(page)
 
 
   await settings.getByRole("tab", { name: "Models" }).click()
   await settings.getByRole("tab", { name: "Models" }).click()
   const search = settings.getByPlaceholder("Search models")
   const search = settings.getByPlaceholder("Search models")
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
   await toggle.locator('[data-slot="switch-control"]').click()
   await toggle.locator('[data-slot="switch-control"]').click()
   await expect(input).toHaveAttribute("aria-checked", "false")
   await expect(input).toHaveAttribute("aria-checked", "false")
 
 
-  await page.keyboard.press("Escape")
-  const closed = await settings
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-  if (!closed) {
-    await page.keyboard.press("Escape")
-    const closedSecond = await settings
-      .waitFor({ state: "detached", timeout: 1500 })
-      .then(() => true)
-      .catch(() => false)
-    if (!closedSecond) {
-      await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
-      await expect(settings).toHaveCount(0)
-    }
-  }
+  await closeDialog(page, settings)
 
 
   await page.locator(promptSelector).click()
   await page.locator(promptSelector).click()
   await page.keyboard.type("/model")
   await page.keyboard.type("/model")

+ 0 - 15
packages/app/e2e/palette.spec.ts

@@ -1,15 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("search palette opens and closes", async ({ page, gotoSession }) => {
-  await gotoSession()
-
-  await page.keyboard.press(`${modKey}+P`)
-
-  const dialog = page.getByRole("dialog")
-  await expect(dialog).toBeVisible()
-  await expect(dialog.getByRole("textbox").first()).toBeVisible()
-
-  await page.keyboard.press("Escape")
-  await expect(dialog).toHaveCount(0)
-})

+ 52 - 0
packages/app/e2e/projects/project-edit.spec.ts

@@ -0,0 +1,52 @@
+import { test, expect } from "../fixtures"
+import { openSidebar } from "../actions"
+
+test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
+  await gotoSession()
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await openSidebar(page)
+
+  const open = async () => {
+    const header = page.locator(".group\\/project").first()
+    await header.hover()
+    const trigger = header.getByRole("button", { name: "More options" }).first()
+    await expect(trigger).toBeVisible()
+    await trigger.click({ force: true })
+
+    const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+    await expect(menu).toBeVisible()
+
+    const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
+    await expect(editItem).toBeVisible()
+    await editItem.click({ force: true })
+
+    const dialog = page.getByRole("dialog")
+    await expect(dialog).toBeVisible()
+    await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+    return dialog
+  }
+
+  const name = `e2e project ${Date.now()}`
+  const startup = `echo e2e_${Date.now()}`
+
+  const dialog = await open()
+
+  const nameInput = dialog.getByLabel("Name")
+  await nameInput.fill(name)
+
+  const startupInput = dialog.getByLabel("Workspace startup script")
+  await startupInput.fill(startup)
+
+  await dialog.getByRole("button", { name: "Save" }).click()
+  await expect(dialog).toHaveCount(0)
+
+  const header = page.locator(".group\\/project").first()
+  await expect(header).toContainText(name)
+
+  const reopened = await open()
+  await expect(reopened.getByLabel("Name")).toHaveValue(name)
+  await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
+  await reopened.getByRole("button", { name: "Cancel" }).click()
+  await expect(reopened).toHaveCount(0)
+})

+ 70 - 0
packages/app/e2e/projects/projects-close.spec.ts

@@ -0,0 +1,70 @@
+import { test, expect } from "../fixtures"
+import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
+import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const otherSlug = dirSlug(other)
+  await seedProjects(page, { directory, extra: [other] })
+
+  try {
+    await gotoSession()
+
+    await openSidebar(page)
+
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.hover()
+
+    const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
+    await expect(close).toBeVisible()
+    await close.click()
+
+    await expect(otherButton).toHaveCount(0)
+  } finally {
+    await cleanupTestProject(other)
+  }
+})
+
+test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const otherName = other.split("/").pop() ?? other
+  const otherSlug = dirSlug(other)
+  await seedProjects(page, { directory, extra: [other] })
+
+  try {
+    await gotoSession()
+
+    await openSidebar(page)
+
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.click()
+
+    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+    const header = page
+      .locator(".group\\/project")
+      .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
+      .first()
+    await expect(header).toContainText(otherName)
+
+    const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
+    await expect(trigger).toHaveCount(1)
+    await trigger.focus()
+    await page.keyboard.press("Enter")
+
+    const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+    await expect(menu).toBeVisible({ timeout: 10_000 })
+
+    await clickMenuItem(menu, /^Close$/i, { force: true })
+    await expect(otherButton).toHaveCount(0)
+  } finally {
+    await cleanupTestProject(other)
+  }
+})

+ 34 - 0
packages/app/e2e/projects/projects-switch.spec.ts

@@ -0,0 +1,34 @@
+import { test, expect } from "../fixtures"
+import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
+import { projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const otherSlug = dirSlug(other)
+
+  await seedProjects(page, { directory, extra: [other] })
+
+  try {
+    await gotoSession()
+
+    await defocus(page)
+
+    const currentSlug = dirSlug(directory)
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.click()
+
+    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+    const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+    await expect(currentButton).toBeVisible()
+    await currentButton.click()
+
+    await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
+  } finally {
+    await cleanupTestProject(other)
+  }
+})

+ 8 - 13
packages/app/e2e/context.spec.ts → packages/app/e2e/prompt/context.spec.ts

@@ -1,16 +1,13 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
 
 
 test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
 test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
   const title = `e2e smoke context ${Date.now()}`
   const title = `e2e smoke context ${Date.now()}`
-  const created = await sdk.session.create({ title }).then((r) => r.data)
 
 
-  if (!created?.id) throw new Error("Session create did not return an id")
-  const sessionID = created.id
-
-  try {
+  await withSession(sdk, title, async (session) => {
     await sdk.session.promptAsync({
     await sdk.session.promptAsync({
-      sessionID,
+      sessionID: session.id,
       noReply: true,
       noReply: true,
       parts: [
       parts: [
         {
         {
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
 
 
     await expect
     await expect
       .poll(async () => {
       .poll(async () => {
-        const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
+        const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
         return messages.length
         return messages.length
       })
       })
       .toBeGreaterThan(0)
       .toBeGreaterThan(0)
 
 
-    await gotoSession(sessionID)
+    await gotoSession(session.id)
 
 
     const contextButton = page
     const contextButton = page
       .locator('[data-component="button"]')
       .locator('[data-component="button"]')
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
 
 
     const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
     const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
     await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
     await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
-  } finally {
-    await sdk.session.delete({ sessionID }).catch(() => undefined)
-  }
+  })
 })
 })

+ 2 - 2
packages/app/e2e/prompt-mention.spec.ts → packages/app/e2e/prompt/prompt-mention.spec.ts

@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
 
 
 test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
 test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 2 - 2
packages/app/e2e/prompt-slash-open.spec.ts → packages/app/e2e/prompt/prompt-slash-open.spec.ts

@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
 
 
 test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
 test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 3 - 7
packages/app/e2e/prompt.spec.ts → packages/app/e2e/prompt/prompt.spec.ts

@@ -1,10 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
-
-function sessionIDFromUrl(url: string) {
-  const match = /\/session\/([^/?#]+)/.exec(url)
-  return match?.[1]
-}
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { sessionIDFromUrl, withSession } from "../actions"
 
 
 test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
 test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
   test.setTimeout(120_000)
   test.setTimeout(120_000)

+ 35 - 0
packages/app/e2e/selectors.ts

@@ -0,0 +1,35 @@
+export const promptSelector = '[data-component="prompt-input"]'
+export const terminalSelector = '[data-component="terminal"]'
+
+export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
+export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
+
+export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
+
+export const projectSwitchSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
+
+export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
+
+export const projectMenuTriggerSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
+
+export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
+
+export const titlebarRightSelector = "#opencode-titlebar-right"
+
+export const popoverBodySelector = '[data-slot="popover-body"]'
+
+export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
+
+export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
+
+export const inlineInputSelector = '[data-component="inline-input"]'
+
+export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
+
+export const listItemSelector = '[data-slot="list-item"]'
+
+export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
+
+export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`

+ 0 - 21
packages/app/e2e/session.spec.ts

@@ -1,21 +0,0 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
-
-test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
-  const title = `e2e smoke ${Date.now()}`
-  const created = await sdk.session.create({ title }).then((r) => r.data)
-
-  if (!created?.id) throw new Error("Session create did not return an id")
-  const sessionID = created.id
-
-  try {
-    await gotoSession(sessionID)
-
-    const prompt = page.locator(promptSelector)
-    await prompt.click()
-    await page.keyboard.type("hello from e2e")
-    await expect(prompt).toContainText("hello from e2e")
-  } finally {
-    await sdk.session.delete({ sessionID }).catch(() => undefined)
-  }
-})

+ 115 - 0
packages/app/e2e/session/session.spec.ts

@@ -0,0 +1,115 @@
+import { test, expect } from "../fixtures"
+import {
+  openSidebar,
+  openSessionMoreMenu,
+  clickMenuItem,
+  confirmDialog,
+  openSharePopover,
+  withSession,
+} from "../actions"
+import { sessionItemSelector, inlineInputSelector } from "../selectors"
+
+const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
+
+test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
+  const stamp = Date.now()
+  const originalTitle = `e2e rename test ${stamp}`
+  const newTitle = `e2e renamed ${stamp}`
+
+  await withSession(sdk, originalTitle, async (session) => {
+    await gotoSession(session.id)
+    await openSidebar(page)
+
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /rename/i)
+
+    const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
+    await expect(input).toBeVisible()
+    await input.fill(newTitle)
+    await input.press("Enter")
+
+    await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
+  })
+})
+
+test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
+  const stamp = Date.now()
+  const title = `e2e archive test ${stamp}`
+
+  await withSession(sdk, title, async (session) => {
+    await gotoSession(session.id)
+    await openSidebar(page)
+
+    const sessionEl = page.locator(sessionItemSelector(session.id))
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /archive/i)
+
+    await expect(sessionEl).not.toBeVisible()
+  })
+})
+
+test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
+  const stamp = Date.now()
+  const title = `e2e delete test ${stamp}`
+
+  await withSession(sdk, title, async (session) => {
+    await gotoSession(session.id)
+    await openSidebar(page)
+
+    const sessionEl = page.locator(sessionItemSelector(session.id))
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /delete/i)
+    await confirmDialog(page, /delete/i)
+
+    await expect(sessionEl).not.toBeVisible()
+  })
+})
+
+test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
+  test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
+
+  const stamp = Date.now()
+  const title = `e2e share test ${stamp}`
+
+  await withSession(sdk, title, async (session) => {
+    await gotoSession(session.id)
+
+    const { rightSection, popoverBody } = await openSharePopover(page)
+    await popoverBody.getByRole("button", { name: "Publish" }).first().click()
+
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
+
+    const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
+    await expect(copyButton).toBeVisible({ timeout: 30_000 })
+
+    const sharedPopover = await openSharePopover(page)
+    const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+    await expect(unpublish).toBeVisible({ timeout: 30_000 })
+    await unpublish.click()
+
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
+
+    await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
+
+    const unsharedPopover = await openSharePopover(page)
+    await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+      timeout: 30_000,
+    })
+  })
+})

+ 0 - 44
packages/app/e2e/settings.spec.ts

@@ -1,44 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
-  await gotoSession()
-
-  const dialog = page.getByRole("dialog")
-
-  await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
-  const opened = await dialog
-    .waitFor({ state: "visible", timeout: 3000 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (!opened) {
-    await page.getByRole("button", { name: "Settings" }).first().click()
-    await expect(dialog).toBeVisible()
-  }
-
-  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
-  await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
-  await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
-
-  await page.keyboard.press("Escape")
-
-  const closed = await dialog
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (closed) return
-
-  await page.keyboard.press("Escape")
-  const closedSecond = await dialog
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (closedSecond) return
-
-  await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
-  await expect(dialog).toHaveCount(0)
-})

+ 28 - 0
packages/app/e2e/settings/settings-language.spec.ts

@@ -0,0 +1,28 @@
+import { test, expect } from "../fixtures"
+import { settingsLanguageSelectSelector } from "../selectors"
+import { openSettings } from "../actions"
+
+test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
+  await page.addInitScript(() => {
+    localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
+  })
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+
+  const heading = dialog.getByRole("heading", { level: 2 })
+  await expect(heading).toHaveText("General")
+
+  const select = dialog.locator(settingsLanguageSelectSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
+
+  await expect(heading).toHaveText("Allgemein")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
+  await expect(heading).toHaveText("General")
+})

+ 5 - 31
packages/app/e2e/settings-providers.spec.ts → packages/app/e2e/settings/settings-providers.spec.ts

@@ -1,22 +1,11 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings, clickListItem } from "../actions"
 
 
 test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
 test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
-  const dialog = page.getByRole("dialog")
-
-  await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
-  const opened = await dialog
-    .waitFor({ state: "visible", timeout: 3000 })
-    .then(() => true)
-    .catch(() => false)
-
-  if (!opened) {
-    await page.getByRole("button", { name: "Settings" }).first().click()
-    await expect(dialog).toBeVisible()
-  }
+  const dialog = await openSettings(page)
 
 
   await dialog.getByRole("tab", { name: "Providers" }).click()
   await dialog.getByRole("tab", { name: "Providers" }).click()
   await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
   await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
@@ -37,20 +26,5 @@ test("smoke providers settings opens provider selector", async ({ page, gotoSess
   const stillOpen = await dialog.isVisible().catch(() => false)
   const stillOpen = await dialog.isVisible().catch(() => false)
   if (!stillOpen) return
   if (!stillOpen) return
 
 
-  await page.keyboard.press("Escape")
-  const closed = await dialog
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-  if (closed) return
-
-  await page.keyboard.press("Escape")
-  const closedSecond = await dialog
-    .waitFor({ state: "detached", timeout: 1500 })
-    .then(() => true)
-    .catch(() => false)
-  if (closedSecond) return
-
-  await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
-  await expect(dialog).toHaveCount(0)
+  await closeDialog(page, dialog)
 })
 })

+ 14 - 0
packages/app/e2e/settings/settings.spec.ts

@@ -0,0 +1,14 @@
+import { test, expect } from "../fixtures"
+import { closeDialog, openSettings } from "../actions"
+
+test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+  await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
+  await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
+
+  await closeDialog(page, dialog)
+})

+ 0 - 21
packages/app/e2e/sidebar.spec.ts

@@ -1,21 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
-  await gotoSession()
-
-  const main = page.locator("main")
-  const closedClass = /xl:border-l/
-  const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
-
-  if (isClosed) {
-    await page.keyboard.press(`${modKey}+B`)
-    await expect(main).not.toHaveClass(closedClass)
-  }
-
-  await page.keyboard.press(`${modKey}+B`)
-  await expect(main).toHaveClass(closedClass)
-
-  await page.keyboard.press(`${modKey}+B`)
-  await expect(main).not.toHaveClass(closedClass)
-})

+ 5 - 35
packages/app/e2e/sidebar-session-links.spec.ts → packages/app/e2e/sidebar/sidebar-session-links.spec.ts

@@ -1,33 +1,8 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { openSidebar, withSession } from "../actions"
+import { promptSelector } from "../selectors"
 
 
-type Locator = {
-  first: () => Locator
-  getAttribute: (name: string) => Promise<string | null>
-  scrollIntoViewIfNeeded: () => Promise<void>
-  click: () => Promise<void>
-}
-
-type Page = {
-  locator: (selector: string) => Locator
-  keyboard: {
-    press: (key: string) => Promise<void>
-  }
-}
-
-type Fixtures = {
-  page: Page
-  slug: string
-  sdk: {
-    session: {
-      create: (input: { title: string }) => Promise<{ data?: { id?: string } }>
-      delete: (input: { sessionID: string }) => Promise<unknown>
-    }
-  }
-  gotoSession: (sessionID?: string) => Promise<void>
-}
-
-test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => {
+test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
   const stamp = Date.now()
   const stamp = Date.now()
 
 
   const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
   const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
@@ -39,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
   try {
   try {
     await gotoSession(one.id)
     await gotoSession(one.id)
 
 
-    const main = page.locator("main")
-    const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
-    if (collapsed) {
-      await page.keyboard.press(`${modKey}+B`)
-      await expect(main).not.toHaveClass(/xl:border-l/)
-    }
+    await openSidebar(page)
 
 
     const target = page.locator(`[data-session-id="${two.id}"] a`).first()
     const target = page.locator(`[data-session-id="${two.id}"] a`).first()
     await expect(target).toBeVisible()
     await expect(target).toBeVisible()

+ 14 - 0
packages/app/e2e/sidebar/sidebar.spec.ts

@@ -0,0 +1,14 @@
+import { test, expect } from "../fixtures"
+import { openSidebar, toggleSidebar } from "../actions"
+
+test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await openSidebar(page)
+
+  await toggleSidebar(page)
+  await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+  await toggleSidebar(page)
+  await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+})

+ 3 - 2
packages/app/e2e/terminal-init.spec.ts → packages/app/e2e/terminal/terminal-init.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector, terminalSelector, terminalToggleKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector, terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
 
 
 test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
 test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 3 - 2
packages/app/e2e/terminal.spec.ts → packages/app/e2e/terminal/terminal.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { terminalSelector, terminalToggleKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
 
 
 test("terminal panel can be toggled", async ({ page, gotoSession }) => {
 test("terminal panel can be toggled", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 1 - 1
packages/app/e2e/thinking-level.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "./fixtures"
 import { test, expect } from "./fixtures"
-import { modelVariantCycleSelector } from "./utils"
+import { modelVariantCycleSelector } from "./selectors"
 
 
 test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
 test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()

+ 0 - 52
packages/app/e2e/titlebar-history.spec.ts

@@ -1,52 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
-
-test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
-  await page.setViewportSize({ width: 1400, height: 800 })
-
-  const stamp = Date.now()
-  const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
-  const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
-
-  if (!one?.id) throw new Error("Session create did not return an id")
-  if (!two?.id) throw new Error("Session create did not return an id")
-
-  try {
-    await gotoSession(one.id)
-
-    const main = page.locator("main")
-    const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
-    if (collapsed) {
-      await page.keyboard.press(`${modKey}+B`)
-      await expect(main).not.toHaveClass(/xl:border-l/)
-    }
-
-    const link = page.locator(`[data-session-id="${two.id}"] a`).first()
-    await expect(link).toBeVisible()
-    await link.scrollIntoViewIfNeeded()
-    await link.click()
-
-    await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
-    await expect(page.locator(promptSelector)).toBeVisible()
-
-    const back = page.getByRole("button", { name: "Back" })
-    const forward = page.getByRole("button", { name: "Forward" })
-
-    await expect(back).toBeVisible()
-    await expect(back).toBeEnabled()
-    await back.click()
-
-    await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
-    await expect(page.locator(promptSelector)).toBeVisible()
-
-    await expect(forward).toBeVisible()
-    await expect(forward).toBeEnabled()
-    await forward.click()
-
-    await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
-    await expect(page.locator(promptSelector)).toBeVisible()
-  } finally {
-    await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
-    await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
-  }
-})

+ 1 - 1
packages/app/e2e/tsconfig.json

@@ -2,7 +2,7 @@
   "extends": "../tsconfig.json",
   "extends": "../tsconfig.json",
   "compilerOptions": {
   "compilerOptions": {
     "noEmit": true,
     "noEmit": true,
-    "types": ["node"]
+    "types": ["node", "bun"]
   },
   },
   "include": ["./**/*.ts"]
   "include": ["./**/*.ts"]
 }
 }

+ 0 - 4
packages/app/e2e/utils.ts

@@ -10,10 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
 export const modKey = process.platform === "darwin" ? "Meta" : "Control"
 export const modKey = process.platform === "darwin" ? "Meta" : "Control"
 export const terminalToggleKey = "Control+Backquote"
 export const terminalToggleKey = "Control+Backquote"
 
 
-export const promptSelector = '[data-component="prompt-input"]'
-export const terminalSelector = '[data-component="terminal"]'
-export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
-
 export function createSdk(directory?: string) {
 export function createSdk(directory?: string) {
   return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
   return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
 }
 }

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/app",
   "name": "@opencode-ai/app",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "exports": {
   "exports": {

+ 1 - 1
packages/app/script/e2e-local.ts

@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
 
 
 const serverEnv = {
 const serverEnv = {
   ...process.env,
   ...process.env,
-  OPENCODE_DISABLE_SHARE: "true",
+  OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
   OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
   OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
   OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
   OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
   OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
   OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",

+ 1 - 1
packages/app/src/components/session/session-header.tsx

@@ -167,7 +167,7 @@ export function SessionHeader() {
                     triggerAs={Button}
                     triggerAs={Button}
                     triggerProps={{
                     triggerProps={{
                       variant: "secondary",
                       variant: "secondary",
-                      class: "rounded-sm w-[60px] h-[24px]",
+                      class: "rounded-sm h-[24px] px-3",
                       classList: { "rounded-r-none": shareUrl() !== undefined },
                       classList: { "rounded-r-none": shareUrl() !== undefined },
                       style: { scale: 1 },
                       style: { scale: 1 },
                     }}
                     }}

+ 1 - 0
packages/app/src/components/settings-general.tsx

@@ -148,6 +148,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.row.language.description")}
               description={language.t("settings.general.row.language.description")}
             >
             >
               <Select
               <Select
+                data-action="settings-language"
                 options={languageOptions()}
                 options={languageOptions()}
                 current={languageOptions().find((o) => o.value === language.locale())}
                 current={languageOptions().find((o) => o.value === language.locale())}
                 value={(o) => o.value}
                 value={(o) => o.value}

+ 13 - 1
packages/app/src/pages/layout.tsx

@@ -2285,6 +2285,8 @@ export default function Layout(props: ParentProps) {
       <button
       <button
         type="button"
         type="button"
         aria-label={projectName()}
         aria-label={projectName()}
+        data-action="project-switch"
+        data-project={base64Encode(props.project.worktree)}
         classList={{
         classList={{
           "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
           "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
           "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
           "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
@@ -2335,6 +2337,8 @@ export default function Layout(props: ParentProps) {
                     icon="circle-x"
                     icon="circle-x"
                     variant="ghost"
                     variant="ghost"
                     class="shrink-0"
                     class="shrink-0"
+                    data-action="project-close-hover"
+                    data-project={base64Encode(props.project.worktree)}
                     aria-label={language.t("common.close")}
                     aria-label={language.t("common.close")}
                     onClick={(event) => {
                     onClick={(event) => {
                       event.stopPropagation()
                       event.stopPropagation()
@@ -2577,6 +2581,8 @@ export default function Layout(props: ParentProps) {
                       as={IconButton}
                       as={IconButton}
                       icon="dot-grid"
                       icon="dot-grid"
                       variant="ghost"
                       variant="ghost"
+                      data-action="project-menu"
+                      data-project={base64Encode(p.worktree)}
                       class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
                       class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
                       aria-label={language.t("common.moreOptions")}
                       aria-label={language.t("common.moreOptions")}
                     />
                     />
@@ -2604,7 +2610,11 @@ export default function Layout(props: ParentProps) {
                           </DropdownMenu.ItemLabel>
                           </DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         </DropdownMenu.Item>
                         <DropdownMenu.Separator />
                         <DropdownMenu.Separator />
-                        <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
+                        <DropdownMenu.Item
+                          data-action="project-close-menu"
+                          data-project={base64Encode(p.worktree)}
+                          onSelect={() => closeProject(p.worktree)}
+                        >
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         </DropdownMenu.Item>
                       </DropdownMenu.Content>
                       </DropdownMenu.Content>
@@ -2814,6 +2824,7 @@ export default function Layout(props: ParentProps) {
       <div class="flex-1 min-h-0 flex">
       <div class="flex-1 min-h-0 flex">
         <nav
         <nav
           aria-label={language.t("sidebar.nav.projectsAndSessions")}
           aria-label={language.t("sidebar.nav.projectsAndSessions")}
+          data-component="sidebar-nav-desktop"
           classList={{
           classList={{
             "hidden xl:block": true,
             "hidden xl:block": true,
             "relative shrink-0": true,
             "relative shrink-0": true,
@@ -2873,6 +2884,7 @@ export default function Layout(props: ParentProps) {
           />
           />
           <nav
           <nav
             aria-label={language.t("sidebar.nav.projectsAndSessions")}
             aria-label={language.t("sidebar.nav.projectsAndSessions")}
+            data-component="sidebar-nav-mobile"
             classList={{
             classList={{
               "@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
               "@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
               "translate-x-0": layout.mobileSidebar.opened(),
               "translate-x-0": layout.mobileSidebar.opened(),

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-app",
   "name": "@opencode-ai/console-app",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
   "name": "@opencode-ai/console-core",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 6 - 5
packages/console/core/script/promote-models.ts

@@ -2,20 +2,21 @@
 
 
 import { $ } from "bun"
 import { $ } from "bun"
 import path from "path"
 import path from "path"
+import os from "os"
 import { ZenData } from "../src/model"
 import { ZenData } from "../src/model"
 
 
 const stage = process.argv[2]
 const stage = process.argv[2]
 if (!stage) throw new Error("Stage is required")
 if (!stage) throw new Error("Stage is required")
 
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const root = path.resolve(process.cwd(), "..", "..", "..")
-const PARTS = 8
+const PARTS = 10
 
 
 // read the secret
 // read the secret
 const ret = await $`bun sst secret list`.cwd(root).text()
 const ret = await $`bun sst secret list`.cwd(root).text()
 const lines = ret.split("\n")
 const lines = ret.split("\n")
 const values = Array.from({ length: PARTS }, (_, i) => {
 const values = Array.from({ length: PARTS }, (_, i) => {
   const value = lines
   const value = lines
-    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
+    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
     ?.split("=")
     ?.split("=")
     .slice(1)
     .slice(1)
     .join("=")
     .join("=")
@@ -27,6 +28,6 @@ const values = Array.from({ length: PARTS }, (_, i) => {
 ZenData.validate(JSON.parse(values.join("")))
 ZenData.validate(JSON.parse(values.join("")))
 
 
 // update the secret
 // update the secret
-for (let i = 0; i < PARTS; i++) {
-  await $`bun sst secret set ZEN_MODELS${i + 1} --stage ${stage} -- ${values[i]}`
-}
+const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
+await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
+await $`bun sst secret load ${envFile.name} --stage ${stage}`.cwd(root)

+ 6 - 5
packages/console/core/script/pull-models.ts

@@ -2,20 +2,21 @@
 
 
 import { $ } from "bun"
 import { $ } from "bun"
 import path from "path"
 import path from "path"
+import os from "os"
 import { ZenData } from "../src/model"
 import { ZenData } from "../src/model"
 
 
 const stage = process.argv[2]
 const stage = process.argv[2]
 if (!stage) throw new Error("Stage is required")
 if (!stage) throw new Error("Stage is required")
 
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const root = path.resolve(process.cwd(), "..", "..", "..")
-const PARTS = 8
+const PARTS = 10
 
 
 // read the secret
 // read the secret
 const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
 const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
 const lines = ret.split("\n")
 const lines = ret.split("\n")
 const values = Array.from({ length: PARTS }, (_, i) => {
 const values = Array.from({ length: PARTS }, (_, i) => {
   const value = lines
   const value = lines
-    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
+    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
     ?.split("=")
     ?.split("=")
     .slice(1)
     .slice(1)
     .join("=")
     .join("=")
@@ -27,6 +28,6 @@ const values = Array.from({ length: PARTS }, (_, i) => {
 ZenData.validate(JSON.parse(values.join("")))
 ZenData.validate(JSON.parse(values.join("")))
 
 
 // update the secret
 // update the secret
-for (let i = 0; i < PARTS; i++) {
-  await $`bun sst secret set ZEN_MODELS${i + 1} -- ${values[i]}`
-}
+const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
+await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
+await $`bun sst secret load ${envFile.name}`.cwd(root)

+ 9 - 7
packages/console/core/script/update-models.ts

@@ -7,18 +7,20 @@ import { ZenData } from "../src/model"
 
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const models = await $`bun sst secret list`.cwd(root).text()
 const models = await $`bun sst secret list`.cwd(root).text()
-const PARTS = 8
+const PARTS = 10
 
 
 // read the line starting with "ZEN_MODELS"
 // read the line starting with "ZEN_MODELS"
 const lines = models.split("\n")
 const lines = models.split("\n")
 const oldValues = Array.from({ length: PARTS }, (_, i) => {
 const oldValues = Array.from({ length: PARTS }, (_, i) => {
   const value = lines
   const value = lines
-    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
+    .find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
     ?.split("=")
     ?.split("=")
     .slice(1)
     .slice(1)
     .join("=")
     .join("=")
-  if (!value) throw new Error(`ZEN_MODELS${i + 1} not found`)
-  return value
+  // TODO
+  //if (!value) throw new Error(`ZEN_MODELS${i + 1} not found`)
+  //return value
+  return value ?? ""
 })
 })
 
 
 // store the prettified json to a temp file
 // store the prettified json to a temp file
@@ -38,6 +40,6 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
   newValue.slice(chunk * i, i === PARTS - 1 ? undefined : chunk * (i + 1)),
   newValue.slice(chunk * i, i === PARTS - 1 ? undefined : chunk * (i + 1)),
 )
 )
 
 
-for (let i = 0; i < PARTS; i++) {
-  await $`bun sst secret set ZEN_MODELS${i + 1} -- ${newValues[i]}`
-}
+const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
+await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}=${v}`).join("\n"))
+await $`bun sst secret load ${envFile.name}`.cwd(root)

+ 3 - 1
packages/console/core/src/model.ts

@@ -75,7 +75,9 @@ export namespace ZenData {
         Resource.ZEN_MODELS5.value +
         Resource.ZEN_MODELS5.value +
         Resource.ZEN_MODELS6.value +
         Resource.ZEN_MODELS6.value +
         Resource.ZEN_MODELS7.value +
         Resource.ZEN_MODELS7.value +
-        Resource.ZEN_MODELS8.value,
+        Resource.ZEN_MODELS8.value +
+        Resource.ZEN_MODELS9.value +
+        Resource.ZEN_MODELS10.value,
     )
     )
     return ModelsSchema.parse(json)
     return ModelsSchema.parse(json)
   })
   })

+ 8 - 0
packages/console/core/sst-env.d.ts

@@ -133,6 +133,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS10": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_MODELS2": {
     "ZEN_MODELS2": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
@@ -161,6 +165,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS9": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_SESSION_SECRET": {
     "ZEN_SESSION_SECRET": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-function",
   "name": "@opencode-ai/console-function",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 8 - 0
packages/console/function/sst-env.d.ts

@@ -133,6 +133,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS10": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_MODELS2": {
     "ZEN_MODELS2": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
@@ -161,6 +165,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS9": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_SESSION_SECRET": {
     "ZEN_SESSION_SECRET": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-mail",
   "name": "@opencode-ai/console-mail",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "dependencies": {
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",
     "@jsx-email/cli": "1.4.3",

+ 8 - 0
packages/console/resource/sst-env.d.ts

@@ -133,6 +133,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS10": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_MODELS2": {
     "ZEN_MODELS2": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
@@ -161,6 +165,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS9": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_SESSION_SECRET": {
     "ZEN_SESSION_SECRET": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "@opencode-ai/desktop",
   "name": "@opencode-ai/desktop",
   "private": true,
   "private": true,
-  "version": "1.1.45",
+  "version": "1.1.48",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/enterprise",
   "name": "@opencode-ai/enterprise",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 8 - 0
packages/enterprise/sst-env.d.ts

@@ -133,6 +133,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS10": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_MODELS2": {
     "ZEN_MODELS2": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
@@ -161,6 +165,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS9": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_SESSION_SECRET": {
     "ZEN_SESSION_SECRET": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string

+ 6 - 6
packages/extensions/zed/extension.toml

@@ -1,7 +1,7 @@
 id = "opencode"
 id = "opencode"
 name = "OpenCode"
 name = "OpenCode"
 description = "The open source coding agent."
 description = "The open source coding agent."
-version = "1.1.45"
+version = "1.1.48"
 schema_version = 1
 schema_version = 1
 authors = ["Anomaly"]
 authors = ["Anomaly"]
 repository = "https://github.com/anomalyco/opencode"
 repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
 icon = "./icons/opencode.svg"
 icon = "./icons/opencode.svg"
 
 
 [agent_servers.opencode.targets.darwin-aarch64]
 [agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-arm64.zip"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.darwin-x86_64]
 [agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-x64.zip"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.linux-aarch64]
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-arm64.tar.gz"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.linux-x86_64]
 [agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-x64.tar.gz"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.windows-x86_64]
 [agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 cmd = "./opencode.exe"
 args = ["acp"]
 args = ["acp"]

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/function",
   "name": "@opencode-ai/function",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 8 - 0
packages/function/sst-env.d.ts

@@ -133,6 +133,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS10": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_MODELS2": {
     "ZEN_MODELS2": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
@@ -161,6 +165,10 @@ declare module "sst" {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }
+    "ZEN_MODELS9": {
+      "type": "sst.sst.Secret"
+      "value": string
+    }
     "ZEN_SESSION_SECRET": {
     "ZEN_SESSION_SECRET": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string

+ 12 - 12
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.1.45",
+  "version": "1.1.48",
   "name": "opencode",
   "name": "opencode",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
@@ -53,25 +53,25 @@
     "@actions/core": "1.11.1",
     "@actions/core": "1.11.1",
     "@actions/github": "6.0.1",
     "@actions/github": "6.0.1",
     "@agentclientprotocol/sdk": "0.13.0",
     "@agentclientprotocol/sdk": "0.13.0",
-    "@ai-sdk/amazon-bedrock": "3.0.73",
-    "@ai-sdk/anthropic": "2.0.57",
+    "@ai-sdk/amazon-bedrock": "3.0.74",
+    "@ai-sdk/anthropic": "2.0.58",
     "@ai-sdk/azure": "2.0.91",
     "@ai-sdk/azure": "2.0.91",
-    "@ai-sdk/cerebras": "1.0.34",
+    "@ai-sdk/cerebras": "1.0.36",
     "@ai-sdk/cohere": "2.0.22",
     "@ai-sdk/cohere": "2.0.22",
-    "@ai-sdk/deepinfra": "1.0.31",
-    "@ai-sdk/gateway": "2.0.25",
+    "@ai-sdk/deepinfra": "1.0.33",
+    "@ai-sdk/gateway": "2.0.30",
     "@ai-sdk/google": "2.0.52",
     "@ai-sdk/google": "2.0.52",
-    "@ai-sdk/google-vertex": "3.0.97",
+    "@ai-sdk/google-vertex": "3.0.98",
     "@ai-sdk/groq": "2.0.34",
     "@ai-sdk/groq": "2.0.34",
     "@ai-sdk/mistral": "2.0.27",
     "@ai-sdk/mistral": "2.0.27",
     "@ai-sdk/openai": "2.0.89",
     "@ai-sdk/openai": "2.0.89",
-    "@ai-sdk/openai-compatible": "1.0.30",
+    "@ai-sdk/openai-compatible": "1.0.32",
     "@ai-sdk/perplexity": "2.0.23",
     "@ai-sdk/perplexity": "2.0.23",
     "@ai-sdk/provider": "2.0.1",
     "@ai-sdk/provider": "2.0.1",
     "@ai-sdk/provider-utils": "3.0.20",
     "@ai-sdk/provider-utils": "3.0.20",
-    "@ai-sdk/togetherai": "1.0.31",
-    "@ai-sdk/vercel": "1.0.31",
-    "@ai-sdk/xai": "2.0.51",
+    "@ai-sdk/togetherai": "1.0.34",
+    "@ai-sdk/vercel": "1.0.33",
+    "@ai-sdk/xai": "2.0.56",
     "@clack/prompts": "1.0.0-alpha.1",
     "@clack/prompts": "1.0.0-alpha.1",
     "@gitlab/gitlab-ai-provider": "3.3.1",
     "@gitlab/gitlab-ai-provider": "3.3.1",
     "@hono/standard-validator": "0.1.5",
     "@hono/standard-validator": "0.1.5",
@@ -84,7 +84,7 @@
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
     "@opencode-ai/util": "workspace:*",
-    "@openrouter/ai-sdk-provider": "1.5.2",
+    "@openrouter/ai-sdk-provider": "1.5.4",
     "@opentui/core": "0.1.75",
     "@opentui/core": "0.1.75",
     "@opentui/solid": "0.1.75",
     "@opentui/solid": "0.1.75",
     "@parcel/watcher": "2.5.1",
     "@parcel/watcher": "2.5.1",

+ 2 - 2
packages/opencode/script/build.ts

@@ -14,11 +14,11 @@ process.chdir(dir)
 
 
 import pkg from "../package.json"
 import pkg from "../package.json"
 import { Script } from "@opencode-ai/script"
 import { Script } from "@opencode-ai/script"
-
+const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
 // Fetch and generate models.dev snapshot
 // Fetch and generate models.dev snapshot
 const modelsData = process.env.MODELS_DEV_API_JSON
 const modelsData = process.env.MODELS_DEV_API_JSON
   ? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
   ? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
-  : await fetch(`https://models.dev/api.json`).then((x) => x.text())
+  : await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
 await Bun.write(
 await Bun.write(
   path.join(dir, "src/provider/models-snapshot.ts"),
   path.join(dir, "src/provider/models-snapshot.ts"),
   `// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
   `// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,

+ 0 - 4
packages/opencode/script/publish.ts

@@ -37,7 +37,6 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
   ),
   ),
 )
 )
 
 
-/*
 const tasks = Object.entries(binaries).map(async ([name]) => {
 const tasks = Object.entries(binaries).map(async ([name]) => {
   if (process.platform !== "win32") {
   if (process.platform !== "win32") {
     await $`chmod -R 755 .`.cwd(`./dist/${name}`)
     await $`chmod -R 755 .`.cwd(`./dist/${name}`)
@@ -53,7 +52,6 @@ const platforms = "linux/amd64,linux/arm64"
 const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
 const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
 const tagFlags = tags.flatMap((t) => ["-t", t])
 const tagFlags = tags.flatMap((t) => ["-t", t])
 await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
 await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
-*/
 
 
 // registries
 // registries
 if (!Script.preview) {
 if (!Script.preview) {
@@ -65,7 +63,6 @@ if (!Script.preview) {
 
 
   const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
   const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
 
 
-  /*
   // arch
   // arch
   const binaryPkgbuild = [
   const binaryPkgbuild = [
     "# Maintainer: dax",
     "# Maintainer: dax",
@@ -179,7 +176,6 @@ if (!Script.preview) {
       }
       }
     }
     }
   }
   }
-  */
 
 
   // Homebrew formula
   // Homebrew formula
   const homebrewFormula = [
   const homebrewFormula = [

+ 1 - 0
packages/opencode/src/cli/cmd/acp.ts

@@ -20,6 +20,7 @@ export const AcpCommand = cmd({
     })
     })
   },
   },
   handler: async (args) => {
   handler: async (args) => {
+    process.env.OPENCODE_CLIENT = "acp"
     await bootstrap(process.cwd(), async () => {
     await bootstrap(process.cwd(), async () => {
       const opts = await resolveNetworkOptions(args)
       const opts = await resolveNetworkOptions(args)
       const server = Server.listen(opts)
       const server = Server.listen(opts)

+ 2 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -104,6 +104,7 @@ export function tui(input: {
   args: Args
   args: Args
   directory?: string
   directory?: string
   fetch?: typeof fetch
   fetch?: typeof fetch
+  headers?: RequestInit["headers"]
   events?: EventSource
   events?: EventSource
   onExit?: () => Promise<void>
   onExit?: () => Promise<void>
 }) {
 }) {
@@ -130,6 +131,7 @@ export function tui(input: {
                         url={input.url}
                         url={input.url}
                         directory={input.directory}
                         directory={input.directory}
                         fetch={input.fetch}
                         fetch={input.fetch}
+                        headers={input.headers}
                         events={input.events}
                         events={input.events}
                       >
                       >
                         <SyncProvider>
                         <SyncProvider>

+ 17 - 4
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -19,21 +19,34 @@ export const AttachCommand = cmd({
         alias: ["s"],
         alias: ["s"],
         type: "string",
         type: "string",
         describe: "session id to continue",
         describe: "session id to continue",
+      })
+      .option("password", {
+        alias: ["p"],
+        type: "string",
+        describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
       }),
       }),
   handler: async (args) => {
   handler: async (args) => {
-    let directory = args.dir
-    if (args.dir) {
+    const directory = (() => {
+      if (!args.dir) return undefined
       try {
       try {
         process.chdir(args.dir)
         process.chdir(args.dir)
-        directory = process.cwd()
+        return process.cwd()
       } catch {
       } catch {
         // If the directory doesn't exist locally (remote attach), pass it through.
         // If the directory doesn't exist locally (remote attach), pass it through.
+        return args.dir
       }
       }
-    }
+    })()
+    const headers = (() => {
+      const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
+      if (!password) return undefined
+      const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
+      return { Authorization: auth }
+    })()
     await tui({
     await tui({
       url: args.url,
       url: args.url,
       args: { sessionID: args.session },
       args: { sessionID: args.session },
       directory,
       directory,
+      headers,
     })
     })
   },
   },
 })
 })

+ 2 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -345,8 +345,9 @@ export function Autocomplete(props: {
     const results: AutocompleteOption[] = [...command.slashes()]
     const results: AutocompleteOption[] = [...command.slashes()]
 
 
     for (const serverCommand of sync.data.command) {
     for (const serverCommand of sync.data.command) {
+      const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
       results.push({
       results.push({
-        display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
+        display: "/" + serverCommand.name + label,
         description: serverCommand.description,
         description: serverCommand.description,
         onSelect: () => {
         onSelect: () => {
           const newText = "/" + serverCommand.name + " "
           const newText = "/" + serverCommand.name + " "

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/tips.tsx

@@ -100,7 +100,7 @@ const TIPS = [
   'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
   'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
   "Define custom formatter commands with file extensions in config",
   "Define custom formatter commands with file extensions in config",
   "OpenCode uses LSP servers for intelligent code analysis",
   "OpenCode uses LSP servers for intelligent code analysis",
-  "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools",
+  "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
   "Tool definitions can invoke scripts written in Python, Go, etc",
   "Tool definitions can invoke scripts written in Python, Go, etc",
   "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
   "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
   "Use plugins to send OS notifications when sessions complete",
   "Use plugins to send OS notifications when sessions complete",

+ 8 - 1
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -9,13 +9,20 @@ export type EventSource = {
 
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
   name: "SDK",
-  init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
+  init: (props: {
+    url: string
+    directory?: string
+    fetch?: typeof fetch
+    headers?: RequestInit["headers"]
+    events?: EventSource
+  }) => {
     const abort = new AbortController()
     const abort = new AbortController()
     const sdk = createOpencodeClient({
     const sdk = createOpencodeClient({
       baseUrl: props.url,
       baseUrl: props.url,
       signal: abort.signal,
       signal: abort.signal,
       directory: props.directory,
       directory: props.directory,
       fetch: props.fetch,
       fetch: props.fetch,
+      headers: props.headers,
     })
     })
 
 
     const emitter = createGlobalEmitter<{
     const emitter = createGlobalEmitter<{

+ 18 - 2
packages/opencode/src/command/index.ts

@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
 import PROMPT_INITIALIZE from "./template/initialize.txt"
 import PROMPT_INITIALIZE from "./template/initialize.txt"
 import PROMPT_REVIEW from "./template/review.txt"
 import PROMPT_REVIEW from "./template/review.txt"
 import { MCP } from "../mcp"
 import { MCP } from "../mcp"
+import { Skill } from "../skill"
 
 
 export namespace Command {
 export namespace Command {
   export const Event = {
   export const Event = {
@@ -26,7 +27,7 @@ export namespace Command {
       description: z.string().optional(),
       description: z.string().optional(),
       agent: z.string().optional(),
       agent: z.string().optional(),
       model: z.string().optional(),
       model: z.string().optional(),
-      mcp: z.boolean().optional(),
+      source: z.enum(["command", "mcp", "skill"]).optional(),
       // workaround for zod not supporting async functions natively so we use getters
       // workaround for zod not supporting async functions natively so we use getters
       // https://zod.dev/v4/changelog?id=zfunction
       // https://zod.dev/v4/changelog?id=zfunction
       template: z.promise(z.string()).or(z.string()),
       template: z.promise(z.string()).or(z.string()),
@@ -94,7 +95,7 @@ export namespace Command {
     for (const [name, prompt] of Object.entries(await MCP.prompts())) {
     for (const [name, prompt] of Object.entries(await MCP.prompts())) {
       result[name] = {
       result[name] = {
         name,
         name,
-        mcp: true,
+        source: "mcp",
         description: prompt.description,
         description: prompt.description,
         get template() {
         get template() {
           // since a getter can't be async we need to manually return a promise here
           // since a getter can't be async we need to manually return a promise here
@@ -118,6 +119,21 @@ export namespace Command {
       }
       }
     }
     }
 
 
+    // Add skills as invokable commands
+    for (const skill of await Skill.all()) {
+      // Skip if a command with this name already exists
+      if (result[skill.name]) continue
+      result[skill.name] = {
+        name: skill.name,
+        description: skill.description,
+        source: "skill",
+        get template() {
+          return skill.content
+        },
+        hints: [],
+      }
+    }
+
     return result
     return result
   })
   })
 
 

+ 0 - 23
packages/opencode/src/config/config.ts

@@ -1078,29 +1078,6 @@ export namespace Config {
         .optional(),
         .optional(),
       experimental: z
       experimental: z
         .object({
         .object({
-          hook: z
-            .object({
-              file_edited: z
-                .record(
-                  z.string(),
-                  z
-                    .object({
-                      command: z.string().array(),
-                      environment: z.record(z.string(), z.string()).optional(),
-                    })
-                    .array(),
-                )
-                .optional(),
-              session_completed: z
-                .object({
-                  command: z.string().array(),
-                  environment: z.record(z.string(), z.string()).optional(),
-                })
-                .array()
-                .optional(),
-            })
-            .optional(),
-          chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
           disable_paste_summary: z.boolean().optional(),
           disable_paste_summary: z.boolean().optional(),
           batch_tool: z.boolean().optional().describe("Enable the batch tool"),
           batch_tool: z.boolean().optional().describe("Enable the batch tool"),
           openTelemetry: z
           openTelemetry: z

+ 3 - 1
packages/opencode/src/env/index.ts

@@ -2,7 +2,9 @@ import { Instance } from "../project/instance"
 
 
 export namespace Env {
 export namespace Env {
   const state = Instance.state(() => {
   const state = Instance.state(() => {
-    return process.env as Record<string, string | undefined>
+    // Create a shallow copy to isolate environment per instance
+    // Prevents parallel tests from interfering with each other's env vars
+    return { ...process.env } as Record<string, string | undefined>
   })
   })
 
 
   export function get(key: string) {
   export function get(key: string) {

+ 3 - 3
packages/opencode/src/file/ripgrep.ts

@@ -214,8 +214,8 @@ export namespace Ripgrep {
     input.signal?.throwIfAborted()
     input.signal?.throwIfAborted()
 
 
     const args = [await filepath(), "--files", "--glob=!.git/*"]
     const args = [await filepath(), "--files", "--glob=!.git/*"]
-    if (input.follow !== false) args.push("--follow")
-    if (input.hidden !== false) args.push("--hidden")
+    if (input.follow) args.push("--follow")
+    if (input.hidden) args.push("--hidden")
     if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
     if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
     if (input.glob) {
     if (input.glob) {
       for (const g of input.glob) {
       for (const g of input.glob) {
@@ -381,7 +381,7 @@ export namespace Ripgrep {
     follow?: boolean
     follow?: boolean
   }) {
   }) {
     const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
     const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
-    if (input.follow !== false) args.push("--follow")
+    if (input.follow) args.push("--follow")
 
 
     if (input.glob) {
     if (input.glob) {
       for (const g of input.glob) {
       for (const g of input.glob) {

+ 13 - 1
packages/opencode/src/flag/flag.ts

@@ -25,7 +25,7 @@ export namespace Flag {
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
   export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
   export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
   export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
   export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
-  export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
+  export declare const OPENCODE_CLIENT: string
   export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
   export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
   export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
   export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
 
 
@@ -47,6 +47,7 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
   export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
   export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
   export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
   export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
+  export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
 
 
   function number(key: string) {
   function number(key: string) {
     const value = process.env[key]
     const value = process.env[key]
@@ -77,3 +78,14 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
   enumerable: true,
   enumerable: true,
   configurable: false,
   configurable: false,
 })
 })
+
+// Dynamic getter for OPENCODE_CLIENT
+// This must be evaluated at access time, not module load time,
+// because some commands override the client at runtime
+Object.defineProperty(Flag, "OPENCODE_CLIENT", {
+  get() {
+    return process.env["OPENCODE_CLIENT"] ?? "cli"
+  },
+  enumerable: true,
+  configurable: false,
+})

+ 1 - 1
packages/opencode/src/provider/models.ts

@@ -85,7 +85,7 @@ export namespace ModelsDev {
   }
   }
 
 
   export const Data = lazy(async () => {
   export const Data = lazy(async () => {
-    const file = Bun.file(filepath)
+    const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
     const result = await file.json().catch(() => {})
     const result = await file.json().catch(() => {})
     if (result) return result
     if (result) return result
     // @ts-ignore
     // @ts-ignore

+ 13 - 12
packages/opencode/src/provider/provider.ts

@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
 import { createOpenAI } from "@ai-sdk/openai"
 import { createOpenAI } from "@ai-sdk/openai"
 import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
 import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
 import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
 import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
-import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
+import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
 import { createXai } from "@ai-sdk/xai"
 import { createXai } from "@ai-sdk/xai"
 import { createMistral } from "@ai-sdk/mistral"
 import { createMistral } from "@ai-sdk/mistral"
 import { createGroq } from "@ai-sdk/groq"
 import { createGroq } from "@ai-sdk/groq"
@@ -195,11 +195,13 @@ export namespace Provider {
 
 
       const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
       const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
 
 
+      // TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
+      // until the scope of the Env API is clarified (test only or runtime?)
       const awsBearerToken = iife(() => {
       const awsBearerToken = iife(() => {
-        const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
+        const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
         if (envToken) return envToken
         if (envToken) return envToken
         if (auth?.type === "api") {
         if (auth?.type === "api") {
-          Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
+          process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
           return auth.key
           return auth.key
         }
         }
         return undefined
         return undefined
@@ -376,17 +378,19 @@ export namespace Provider {
     },
     },
     "sap-ai-core": async () => {
     "sap-ai-core": async () => {
       const auth = await Auth.get("sap-ai-core")
       const auth = await Auth.get("sap-ai-core")
+      // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
+      // until the scope of the Env API is clarified (test only or runtime?)
       const envServiceKey = iife(() => {
       const envServiceKey = iife(() => {
-        const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
+        const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
         if (envAICoreServiceKey) return envAICoreServiceKey
         if (envAICoreServiceKey) return envAICoreServiceKey
         if (auth?.type === "api") {
         if (auth?.type === "api") {
-          Env.set("AICORE_SERVICE_KEY", auth.key)
+          process.env.AICORE_SERVICE_KEY = auth.key
           return auth.key
           return auth.key
         }
         }
         return undefined
         return undefined
       })
       })
-      const deploymentId = Env.get("AICORE_DEPLOYMENT_ID")
-      const resourceGroup = Env.get("AICORE_RESOURCE_GROUP")
+      const deploymentId = process.env.AICORE_DEPLOYMENT_ID
+      const resourceGroup = process.env.AICORE_RESOURCE_GROUP
 
 
       return {
       return {
         autoload: !!envServiceKey,
         autoload: !!envServiceKey,
@@ -1023,12 +1027,9 @@ export namespace Provider {
         })
         })
       }
       }
 
 
-      // Special case: google-vertex-anthropic uses a subpath import
-      const bundledKey =
-        model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm
-      const bundledFn = BUNDLED_PROVIDERS[bundledKey]
+      const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
       if (bundledFn) {
       if (bundledFn) {
-        log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey })
+        log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm })
         const loaded = bundledFn({
         const loaded = bundledFn({
           name: model.providerID,
           name: model.providerID,
           ...options,
           ...options,

+ 0 - 0
packages/opencode/src/provider/sdk/openai-compatible/src/README.md → packages/opencode/src/provider/sdk/copilot/README.md


+ 169 - 0
packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts

@@ -0,0 +1,169 @@
+import {
+  type LanguageModelV2Prompt,
+  type SharedV2ProviderMetadata,
+  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 }) {
+  return message?.providerOptions?.copilot ?? {}
+}
+
+export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Prompt): OpenAICompatibleChatPrompt {
+  const messages: OpenAICompatibleChatPrompt = []
+  for (const { role, content, ...message } of prompt) {
+    const metadata = getOpenAIMetadata({ ...message })
+    switch (role) {
+      case "system": {
+        messages.push({
+          role: "system",
+          content: [
+            {
+              type: "text",
+              text: content,
+            },
+          ],
+          ...metadata,
+        })
+        break
+      }
+
+      case "user": {
+        if (content.length === 1 && content[0].type === "text") {
+          messages.push({
+            role: "user",
+            content: content[0].text,
+            ...getOpenAIMetadata(content[0]),
+          })
+          break
+        }
+
+        messages.push({
+          role: "user",
+          content: content.map((part) => {
+            const partMetadata = getOpenAIMetadata(part)
+            switch (part.type) {
+              case "text": {
+                return { type: "text", text: part.text, ...partMetadata }
+              }
+              case "file": {
+                if (part.mediaType.startsWith("image/")) {
+                  const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
+
+                  return {
+                    type: "image_url",
+                    image_url: {
+                      url:
+                        part.data instanceof URL
+                          ? part.data.toString()
+                          : `data:${mediaType};base64,${convertToBase64(part.data)}`,
+                    },
+                    ...partMetadata,
+                  }
+                } else {
+                  throw new UnsupportedFunctionalityError({
+                    functionality: `file part media type ${part.mediaType}`,
+                  })
+                }
+              }
+            }
+          }),
+          ...metadata,
+        })
+
+        break
+      }
+
+      case "assistant": {
+        let text = ""
+        let reasoningText: string | undefined
+        let reasoningOpaque: string | undefined
+        const toolCalls: Array<{
+          id: string
+          type: "function"
+          function: { name: string; arguments: string }
+        }> = []
+
+        for (const part of content) {
+          const partMetadata = getOpenAIMetadata(part)
+          // Check for reasoningOpaque on any part (may be attached to text/tool-call)
+          const partOpaque = (part.providerOptions as { copilot?: { reasoningOpaque?: string } })?.copilot
+            ?.reasoningOpaque
+          if (partOpaque && !reasoningOpaque) {
+            reasoningOpaque = partOpaque
+          }
+
+          switch (part.type) {
+            case "text": {
+              text += part.text
+              break
+            }
+            case "reasoning": {
+              reasoningText = part.text
+              break
+            }
+            case "tool-call": {
+              toolCalls.push({
+                id: part.toolCallId,
+                type: "function",
+                function: {
+                  name: part.toolName,
+                  arguments: JSON.stringify(part.input),
+                },
+                ...partMetadata,
+              })
+              break
+            }
+          }
+        }
+
+        messages.push({
+          role: "assistant",
+          content: text || null,
+          tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
+          reasoning_text: reasoningText,
+          reasoning_opaque: reasoningOpaque,
+          ...metadata,
+        })
+
+        break
+      }
+
+      case "tool": {
+        for (const toolResponse of content) {
+          const output = toolResponse.output
+
+          let contentValue: string
+          switch (output.type) {
+            case "text":
+            case "error-text":
+              contentValue = output.value
+              break
+            case "content":
+            case "json":
+            case "error-json":
+              contentValue = JSON.stringify(output.value)
+              break
+          }
+
+          const toolResponseMetadata = getOpenAIMetadata(toolResponse)
+          messages.push({
+            role: "tool",
+            tool_call_id: toolResponse.toolCallId,
+            content: contentValue,
+            ...toolResponseMetadata,
+          })
+        }
+        break
+      }
+
+      default: {
+        const _exhaustiveCheck: never = role
+        throw new Error(`Unsupported role: ${_exhaustiveCheck}`)
+      }
+    }
+  }
+
+  return messages
+}

+ 15 - 0
packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts

@@ -0,0 +1,15 @@
+export function getResponseMetadata({
+  id,
+  model,
+  created,
+}: {
+  id?: string | undefined | null
+  created?: number | undefined | null
+  model?: string | undefined | null
+}) {
+  return {
+    id: id ?? undefined,
+    modelId: model ?? undefined,
+    timestamp: created != null ? new Date(created * 1000) : undefined,
+  }
+}

+ 17 - 0
packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts

@@ -0,0 +1,17 @@
+import type { LanguageModelV2FinishReason } from "@ai-sdk/provider"
+
+export function mapOpenAICompatibleFinishReason(finishReason: string | null | undefined): LanguageModelV2FinishReason {
+  switch (finishReason) {
+    case "stop":
+      return "stop"
+    case "length":
+      return "length"
+    case "content_filter":
+      return "content-filter"
+    case "function_call":
+    case "tool_calls":
+      return "tool-calls"
+    default:
+      return "unknown"
+  }
+}

+ 64 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts

@@ -0,0 +1,64 @@
+import type { JSONValue } from "@ai-sdk/provider"
+
+export type OpenAICompatibleChatPrompt = Array<OpenAICompatibleMessage>
+
+export type OpenAICompatibleMessage =
+  | OpenAICompatibleSystemMessage
+  | OpenAICompatibleUserMessage
+  | OpenAICompatibleAssistantMessage
+  | OpenAICompatibleToolMessage
+
+// Allow for arbitrary additional properties for general purpose
+// provider-metadata-specific extensibility.
+type JsonRecord<T = never> = Record<string, JSONValue | JSONValue[] | T | T[] | undefined>
+
+export interface OpenAICompatibleSystemMessage extends JsonRecord<OpenAICompatibleSystemContentPart> {
+  role: "system"
+  content: string | Array<OpenAICompatibleSystemContentPart>
+}
+
+export interface OpenAICompatibleSystemContentPart extends JsonRecord {
+  type: "text"
+  text: string
+}
+
+export interface OpenAICompatibleUserMessage extends JsonRecord<OpenAICompatibleContentPart> {
+  role: "user"
+  content: string | Array<OpenAICompatibleContentPart>
+}
+
+export type OpenAICompatibleContentPart = OpenAICompatibleContentPartText | OpenAICompatibleContentPartImage
+
+export interface OpenAICompatibleContentPartImage extends JsonRecord {
+  type: "image_url"
+  image_url: { url: string }
+}
+
+export interface OpenAICompatibleContentPartText extends JsonRecord {
+  type: "text"
+  text: string
+}
+
+export interface OpenAICompatibleAssistantMessage extends JsonRecord<OpenAICompatibleMessageToolCall> {
+  role: "assistant"
+  content?: string | null
+  tool_calls?: Array<OpenAICompatibleMessageToolCall>
+  // Copilot-specific reasoning fields
+  reasoning_text?: string
+  reasoning_opaque?: string
+}
+
+export interface OpenAICompatibleMessageToolCall extends JsonRecord {
+  type: "function"
+  id: string
+  function: {
+    arguments: string
+    name: string
+  }
+}
+
+export interface OpenAICompatibleToolMessage extends JsonRecord {
+  role: "tool"
+  content: string
+  tool_call_id: string
+}

+ 765 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts

@@ -0,0 +1,765 @@
+import {
+  APICallError,
+  InvalidResponseDataError,
+  type LanguageModelV2,
+  type LanguageModelV2CallWarning,
+  type LanguageModelV2Content,
+  type LanguageModelV2FinishReason,
+  type LanguageModelV2StreamPart,
+  type SharedV2ProviderMetadata,
+} from "@ai-sdk/provider"
+import {
+  combineHeaders,
+  createEventSourceResponseHandler,
+  createJsonErrorResponseHandler,
+  createJsonResponseHandler,
+  type FetchFunction,
+  generateId,
+  isParsableJson,
+  parseProviderOptions,
+  type ParseResult,
+  postJsonToApi,
+  type ResponseHandler,
+} from "@ai-sdk/provider-utils"
+import { z } from "zod/v4"
+import { convertToOpenAICompatibleChatMessages } from "./convert-to-openai-compatible-chat-messages"
+import { getResponseMetadata } from "./get-response-metadata"
+import { mapOpenAICompatibleFinishReason } from "./map-openai-compatible-finish-reason"
+import { type OpenAICompatibleChatModelId, openaiCompatibleProviderOptions } from "./openai-compatible-chat-options"
+import { defaultOpenAICompatibleErrorStructure, type ProviderErrorStructure } from "../openai-compatible-error"
+import type { MetadataExtractor } from "./openai-compatible-metadata-extractor"
+import { prepareTools } from "./openai-compatible-prepare-tools"
+
+export type OpenAICompatibleChatConfig = {
+  provider: string
+  headers: () => Record<string, string | undefined>
+  url: (options: { modelId: string; path: string }) => string
+  fetch?: FetchFunction
+  includeUsage?: boolean
+  errorStructure?: ProviderErrorStructure<any>
+  metadataExtractor?: MetadataExtractor
+
+  /**
+   * Whether the model supports structured outputs.
+   */
+  supportsStructuredOutputs?: boolean
+
+  /**
+   * The supported URLs for the model.
+   */
+  supportedUrls?: () => LanguageModelV2["supportedUrls"]
+}
+
+export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
+  readonly specificationVersion = "v2"
+
+  readonly supportsStructuredOutputs: boolean
+
+  readonly modelId: OpenAICompatibleChatModelId
+  private readonly config: OpenAICompatibleChatConfig
+  private readonly failedResponseHandler: ResponseHandler<APICallError>
+  private readonly chunkSchema // type inferred via constructor
+
+  constructor(modelId: OpenAICompatibleChatModelId, config: OpenAICompatibleChatConfig) {
+    this.modelId = modelId
+    this.config = config
+
+    // initialize error handling:
+    const errorStructure = config.errorStructure ?? defaultOpenAICompatibleErrorStructure
+    this.chunkSchema = createOpenAICompatibleChatChunkSchema(errorStructure.errorSchema)
+    this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure)
+
+    this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false
+  }
+
+  get provider(): string {
+    return this.config.provider
+  }
+
+  private get providerOptionsName(): string {
+    return this.config.provider.split(".")[0].trim()
+  }
+
+  get supportedUrls() {
+    return this.config.supportedUrls?.() ?? {}
+  }
+
+  private async getArgs({
+    prompt,
+    maxOutputTokens,
+    temperature,
+    topP,
+    topK,
+    frequencyPenalty,
+    presencePenalty,
+    providerOptions,
+    stopSequences,
+    responseFormat,
+    seed,
+    toolChoice,
+    tools,
+  }: Parameters<LanguageModelV2["doGenerate"]>[0]) {
+    const warnings: LanguageModelV2CallWarning[] = []
+
+    // Parse provider options
+    const compatibleOptions = Object.assign(
+      (await parseProviderOptions({
+        provider: "copilot",
+        providerOptions,
+        schema: openaiCompatibleProviderOptions,
+      })) ?? {},
+      (await parseProviderOptions({
+        provider: this.providerOptionsName,
+        providerOptions,
+        schema: openaiCompatibleProviderOptions,
+      })) ?? {},
+    )
+
+    if (topK != null) {
+      warnings.push({ type: "unsupported-setting", setting: "topK" })
+    }
+
+    if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) {
+      warnings.push({
+        type: "unsupported-setting",
+        setting: "responseFormat",
+        details: "JSON response format schema is only supported with structuredOutputs",
+      })
+    }
+
+    const {
+      tools: openaiTools,
+      toolChoice: openaiToolChoice,
+      toolWarnings,
+    } = prepareTools({
+      tools,
+      toolChoice,
+    })
+
+    return {
+      args: {
+        // model id:
+        model: this.modelId,
+
+        // model specific settings:
+        user: compatibleOptions.user,
+
+        // standardized settings:
+        max_tokens: maxOutputTokens,
+        temperature,
+        top_p: topP,
+        frequency_penalty: frequencyPenalty,
+        presence_penalty: presencePenalty,
+        response_format:
+          responseFormat?.type === "json"
+            ? this.supportsStructuredOutputs === true && responseFormat.schema != null
+              ? {
+                  type: "json_schema",
+                  json_schema: {
+                    schema: responseFormat.schema,
+                    name: responseFormat.name ?? "response",
+                    description: responseFormat.description,
+                  },
+                }
+              : { type: "json_object" }
+            : undefined,
+
+        stop: stopSequences,
+        seed,
+        ...Object.fromEntries(
+          Object.entries(providerOptions?.[this.providerOptionsName] ?? {}).filter(
+            ([key]) => !Object.keys(openaiCompatibleProviderOptions.shape).includes(key),
+          ),
+        ),
+
+        reasoning_effort: compatibleOptions.reasoningEffort,
+        verbosity: compatibleOptions.textVerbosity,
+
+        // messages:
+        messages: convertToOpenAICompatibleChatMessages(prompt),
+
+        // tools:
+        tools: openaiTools,
+        tool_choice: openaiToolChoice,
+
+        // thinking_budget
+        thinking_budget: compatibleOptions.thinking_budget,
+      },
+      warnings: [...warnings, ...toolWarnings],
+    }
+  }
+
+  async doGenerate(
+    options: Parameters<LanguageModelV2["doGenerate"]>[0],
+  ): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> {
+    const { args, warnings } = await this.getArgs({ ...options })
+
+    const body = JSON.stringify(args)
+
+    const {
+      responseHeaders,
+      value: responseBody,
+      rawValue: rawResponse,
+    } = await postJsonToApi({
+      url: this.config.url({
+        path: "/chat/completions",
+        modelId: this.modelId,
+      }),
+      headers: combineHeaders(this.config.headers(), options.headers),
+      body: args,
+      failedResponseHandler: this.failedResponseHandler,
+      successfulResponseHandler: createJsonResponseHandler(OpenAICompatibleChatResponseSchema),
+      abortSignal: options.abortSignal,
+      fetch: this.config.fetch,
+    })
+
+    const choice = responseBody.choices[0]
+    const content: Array<LanguageModelV2Content> = []
+
+    // text content:
+    const text = choice.message.content
+    if (text != null && text.length > 0) {
+      content.push({ type: "text", text })
+    }
+
+    // reasoning content (Copilot uses reasoning_text):
+    const reasoning = choice.message.reasoning_text
+    if (reasoning != null && reasoning.length > 0) {
+      content.push({
+        type: "reasoning",
+        text: reasoning,
+        // Include reasoning_opaque for Copilot multi-turn reasoning
+        providerMetadata: choice.message.reasoning_opaque
+          ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+          : undefined,
+      })
+    }
+
+    // tool calls:
+    if (choice.message.tool_calls != null) {
+      for (const toolCall of choice.message.tool_calls) {
+        content.push({
+          type: "tool-call",
+          toolCallId: toolCall.id ?? generateId(),
+          toolName: toolCall.function.name,
+          input: toolCall.function.arguments!,
+        })
+      }
+    }
+
+    // provider metadata:
+    const providerMetadata: SharedV2ProviderMetadata = {
+      [this.providerOptionsName]: {},
+      ...(await this.config.metadataExtractor?.extractMetadata?.({
+        parsedBody: rawResponse,
+      })),
+    }
+    const completionTokenDetails = responseBody.usage?.completion_tokens_details
+    if (completionTokenDetails?.accepted_prediction_tokens != null) {
+      providerMetadata[this.providerOptionsName].acceptedPredictionTokens =
+        completionTokenDetails?.accepted_prediction_tokens
+    }
+    if (completionTokenDetails?.rejected_prediction_tokens != null) {
+      providerMetadata[this.providerOptionsName].rejectedPredictionTokens =
+        completionTokenDetails?.rejected_prediction_tokens
+    }
+
+    return {
+      content,
+      finishReason: mapOpenAICompatibleFinishReason(choice.finish_reason),
+      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,
+      },
+      providerMetadata,
+      request: { body },
+      response: {
+        ...getResponseMetadata(responseBody),
+        headers: responseHeaders,
+        body: rawResponse,
+      },
+      warnings,
+    }
+  }
+
+  async doStream(
+    options: Parameters<LanguageModelV2["doStream"]>[0],
+  ): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> {
+    const { args, warnings } = await this.getArgs({ ...options })
+
+    const body = {
+      ...args,
+      stream: true,
+
+      // only include stream_options when in strict compatibility mode:
+      stream_options: this.config.includeUsage ? { include_usage: true } : undefined,
+    }
+
+    const metadataExtractor = this.config.metadataExtractor?.createStreamExtractor()
+
+    const { responseHeaders, value: response } = await postJsonToApi({
+      url: this.config.url({
+        path: "/chat/completions",
+        modelId: this.modelId,
+      }),
+      headers: combineHeaders(this.config.headers(), options.headers),
+      body,
+      failedResponseHandler: this.failedResponseHandler,
+      successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema),
+      abortSignal: options.abortSignal,
+      fetch: this.config.fetch,
+    })
+
+    const toolCalls: Array<{
+      id: string
+      type: "function"
+      function: {
+        name: string
+        arguments: string
+      }
+      hasFinished: boolean
+    }> = []
+
+    let finishReason: LanguageModelV2FinishReason = "unknown"
+    const usage: {
+      completionTokens: number | undefined
+      completionTokensDetails: {
+        reasoningTokens: number | undefined
+        acceptedPredictionTokens: number | undefined
+        rejectedPredictionTokens: number | undefined
+      }
+      promptTokens: number | undefined
+      promptTokensDetails: {
+        cachedTokens: number | undefined
+      }
+      totalTokens: number | undefined
+    } = {
+      completionTokens: undefined,
+      completionTokensDetails: {
+        reasoningTokens: undefined,
+        acceptedPredictionTokens: undefined,
+        rejectedPredictionTokens: undefined,
+      },
+      promptTokens: undefined,
+      promptTokensDetails: {
+        cachedTokens: undefined,
+      },
+      totalTokens: undefined,
+    }
+    let isFirstChunk = true
+    const providerOptionsName = this.providerOptionsName
+    let isActiveReasoning = false
+    let isActiveText = false
+    let reasoningOpaque: string | undefined
+
+    return {
+      stream: response.pipeThrough(
+        new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV2StreamPart>({
+          start(controller) {
+            controller.enqueue({ type: "stream-start", warnings })
+          },
+
+          // TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX
+          transform(chunk, controller) {
+            // Emit raw chunk if requested (before anything else)
+            if (options.includeRawChunks) {
+              controller.enqueue({ type: "raw", rawValue: chunk.rawValue })
+            }
+
+            // handle failed chunk parsing / validation:
+            if (!chunk.success) {
+              finishReason = "error"
+              controller.enqueue({ type: "error", error: chunk.error })
+              return
+            }
+            const value = chunk.value
+
+            metadataExtractor?.processChunk(chunk.rawValue)
+
+            // handle error chunks:
+            if ("error" in value) {
+              finishReason = "error"
+              controller.enqueue({ type: "error", error: value.error.message })
+              return
+            }
+
+            if (isFirstChunk) {
+              isFirstChunk = false
+
+              controller.enqueue({
+                type: "response-metadata",
+                ...getResponseMetadata(value),
+              })
+            }
+
+            if (value.usage != null) {
+              const {
+                prompt_tokens,
+                completion_tokens,
+                total_tokens,
+                prompt_tokens_details,
+                completion_tokens_details,
+              } = value.usage
+
+              usage.promptTokens = prompt_tokens ?? undefined
+              usage.completionTokens = completion_tokens ?? undefined
+              usage.totalTokens = total_tokens ?? undefined
+              if (completion_tokens_details?.reasoning_tokens != null) {
+                usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
+              }
+              if (completion_tokens_details?.accepted_prediction_tokens != null) {
+                usage.completionTokensDetails.acceptedPredictionTokens =
+                  completion_tokens_details?.accepted_prediction_tokens
+              }
+              if (completion_tokens_details?.rejected_prediction_tokens != null) {
+                usage.completionTokensDetails.rejectedPredictionTokens =
+                  completion_tokens_details?.rejected_prediction_tokens
+              }
+              if (prompt_tokens_details?.cached_tokens != null) {
+                usage.promptTokensDetails.cachedTokens = prompt_tokens_details?.cached_tokens
+              }
+            }
+
+            const choice = value.choices[0]
+
+            if (choice?.finish_reason != null) {
+              finishReason = mapOpenAICompatibleFinishReason(choice.finish_reason)
+            }
+
+            if (choice?.delta == null) {
+              return
+            }
+
+            const delta = choice.delta
+
+            // Capture reasoning_opaque for Copilot multi-turn reasoning
+            if (delta.reasoning_opaque) {
+              if (reasoningOpaque != null) {
+                throw new InvalidResponseDataError({
+                  data: delta,
+                  message:
+                    "Multiple reasoning_opaque values received in a single response. Only one thinking part per response is supported.",
+                })
+              }
+              reasoningOpaque = delta.reasoning_opaque
+            }
+
+            // enqueue reasoning before text deltas (Copilot uses reasoning_text):
+            const reasoningContent = delta.reasoning_text
+            if (reasoningContent) {
+              if (!isActiveReasoning) {
+                controller.enqueue({
+                  type: "reasoning-start",
+                  id: "reasoning-0",
+                })
+                isActiveReasoning = true
+              }
+
+              controller.enqueue({
+                type: "reasoning-delta",
+                id: "reasoning-0",
+                delta: reasoningContent,
+              })
+            }
+
+            if (delta.content) {
+              // If reasoning was active and we're starting text, end reasoning first
+              // This handles the case where reasoning_opaque and content come in the same chunk
+              if (isActiveReasoning && !isActiveText) {
+                controller.enqueue({
+                  type: "reasoning-end",
+                  id: "reasoning-0",
+                  providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+                })
+                isActiveReasoning = false
+              }
+
+              if (!isActiveText) {
+                controller.enqueue({ type: "text-start", id: "txt-0" })
+                isActiveText = true
+              }
+
+              controller.enqueue({
+                type: "text-delta",
+                id: "txt-0",
+                delta: delta.content,
+              })
+            }
+
+            if (delta.tool_calls != null) {
+              // If reasoning was active and we're starting tool calls, end reasoning first
+              // This handles the case where reasoning goes directly to tool calls with no content
+              if (isActiveReasoning) {
+                controller.enqueue({
+                  type: "reasoning-end",
+                  id: "reasoning-0",
+                  providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+                })
+                isActiveReasoning = false
+              }
+              for (const toolCallDelta of delta.tool_calls) {
+                const index = toolCallDelta.index
+
+                if (toolCalls[index] == null) {
+                  if (toolCallDelta.id == null) {
+                    throw new InvalidResponseDataError({
+                      data: toolCallDelta,
+                      message: `Expected 'id' to be a string.`,
+                    })
+                  }
+
+                  if (toolCallDelta.function?.name == null) {
+                    throw new InvalidResponseDataError({
+                      data: toolCallDelta,
+                      message: `Expected 'function.name' to be a string.`,
+                    })
+                  }
+
+                  controller.enqueue({
+                    type: "tool-input-start",
+                    id: toolCallDelta.id,
+                    toolName: toolCallDelta.function.name,
+                  })
+
+                  toolCalls[index] = {
+                    id: toolCallDelta.id,
+                    type: "function",
+                    function: {
+                      name: toolCallDelta.function.name,
+                      arguments: toolCallDelta.function.arguments ?? "",
+                    },
+                    hasFinished: false,
+                  }
+
+                  const toolCall = toolCalls[index]
+
+                  if (toolCall.function?.name != null && toolCall.function?.arguments != null) {
+                    // send delta if the argument text has already started:
+                    if (toolCall.function.arguments.length > 0) {
+                      controller.enqueue({
+                        type: "tool-input-delta",
+                        id: toolCall.id,
+                        delta: toolCall.function.arguments,
+                      })
+                    }
+
+                    // check if tool call is complete
+                    // (some providers send the full tool call in one chunk):
+                    if (isParsableJson(toolCall.function.arguments)) {
+                      controller.enqueue({
+                        type: "tool-input-end",
+                        id: toolCall.id,
+                      })
+
+                      controller.enqueue({
+                        type: "tool-call",
+                        toolCallId: toolCall.id ?? generateId(),
+                        toolName: toolCall.function.name,
+                        input: toolCall.function.arguments,
+                      })
+                      toolCall.hasFinished = true
+                    }
+                  }
+
+                  continue
+                }
+
+                // existing tool call, merge if not finished
+                const toolCall = toolCalls[index]
+
+                if (toolCall.hasFinished) {
+                  continue
+                }
+
+                if (toolCallDelta.function?.arguments != null) {
+                  toolCall.function!.arguments += toolCallDelta.function?.arguments ?? ""
+                }
+
+                // send delta
+                controller.enqueue({
+                  type: "tool-input-delta",
+                  id: toolCall.id,
+                  delta: toolCallDelta.function.arguments ?? "",
+                })
+
+                // check if tool call is complete
+                if (
+                  toolCall.function?.name != null &&
+                  toolCall.function?.arguments != null &&
+                  isParsableJson(toolCall.function.arguments)
+                ) {
+                  controller.enqueue({
+                    type: "tool-input-end",
+                    id: toolCall.id,
+                  })
+
+                  controller.enqueue({
+                    type: "tool-call",
+                    toolCallId: toolCall.id ?? generateId(),
+                    toolName: toolCall.function.name,
+                    input: toolCall.function.arguments,
+                  })
+                  toolCall.hasFinished = true
+                }
+              }
+            }
+          },
+
+          flush(controller) {
+            if (isActiveReasoning) {
+              controller.enqueue({
+                type: "reasoning-end",
+                id: "reasoning-0",
+                // Include reasoning_opaque for Copilot multi-turn reasoning
+                providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+              })
+            }
+
+            if (isActiveText) {
+              controller.enqueue({ type: "text-end", id: "txt-0" })
+            }
+
+            // go through all tool calls and send the ones that are not finished
+            for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) {
+              controller.enqueue({
+                type: "tool-input-end",
+                id: toolCall.id,
+              })
+
+              controller.enqueue({
+                type: "tool-call",
+                toolCallId: toolCall.id ?? generateId(),
+                toolName: toolCall.function.name,
+                input: toolCall.function.arguments,
+              })
+            }
+
+            const providerMetadata: SharedV2ProviderMetadata = {
+              [providerOptionsName]: {},
+              // Include reasoning_opaque for Copilot multi-turn reasoning
+              ...(reasoningOpaque ? { copilot: { reasoningOpaque } } : {}),
+              ...metadataExtractor?.buildMetadata(),
+            }
+            if (usage.completionTokensDetails.acceptedPredictionTokens != null) {
+              providerMetadata[providerOptionsName].acceptedPredictionTokens =
+                usage.completionTokensDetails.acceptedPredictionTokens
+            }
+            if (usage.completionTokensDetails.rejectedPredictionTokens != null) {
+              providerMetadata[providerOptionsName].rejectedPredictionTokens =
+                usage.completionTokensDetails.rejectedPredictionTokens
+            }
+
+            controller.enqueue({
+              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,
+              },
+              providerMetadata,
+            })
+          },
+        }),
+      ),
+      request: { body },
+      response: { headers: responseHeaders },
+    }
+  }
+}
+
+const openaiCompatibleTokenUsageSchema = z
+  .object({
+    prompt_tokens: z.number().nullish(),
+    completion_tokens: z.number().nullish(),
+    total_tokens: z.number().nullish(),
+    prompt_tokens_details: z
+      .object({
+        cached_tokens: z.number().nullish(),
+      })
+      .nullish(),
+    completion_tokens_details: z
+      .object({
+        reasoning_tokens: z.number().nullish(),
+        accepted_prediction_tokens: z.number().nullish(),
+        rejected_prediction_tokens: z.number().nullish(),
+      })
+      .nullish(),
+  })
+  .nullish()
+
+// limited version of the schema, focussed on what is needed for the implementation
+// this approach limits breakages when the API changes and increases efficiency
+const OpenAICompatibleChatResponseSchema = z.object({
+  id: z.string().nullish(),
+  created: z.number().nullish(),
+  model: z.string().nullish(),
+  choices: z.array(
+    z.object({
+      message: z.object({
+        role: z.literal("assistant").nullish(),
+        content: z.string().nullish(),
+        // Copilot-specific reasoning fields
+        reasoning_text: z.string().nullish(),
+        reasoning_opaque: z.string().nullish(),
+        tool_calls: z
+          .array(
+            z.object({
+              id: z.string().nullish(),
+              function: z.object({
+                name: z.string(),
+                arguments: z.string(),
+              }),
+            }),
+          )
+          .nullish(),
+      }),
+      finish_reason: z.string().nullish(),
+    }),
+  ),
+  usage: openaiCompatibleTokenUsageSchema,
+})
+
+// limited version of the schema, focussed on what is needed for the implementation
+// this approach limits breakages when the API changes and increases efficiency
+const createOpenAICompatibleChatChunkSchema = <ERROR_SCHEMA extends z.core.$ZodType>(errorSchema: ERROR_SCHEMA) =>
+  z.union([
+    z.object({
+      id: z.string().nullish(),
+      created: z.number().nullish(),
+      model: z.string().nullish(),
+      choices: z.array(
+        z.object({
+          delta: z
+            .object({
+              role: z.enum(["assistant"]).nullish(),
+              content: z.string().nullish(),
+              // Copilot-specific reasoning fields
+              reasoning_text: z.string().nullish(),
+              reasoning_opaque: z.string().nullish(),
+              tool_calls: z
+                .array(
+                  z.object({
+                    index: z.number(),
+                    id: z.string().nullish(),
+                    function: z.object({
+                      name: z.string().nullish(),
+                      arguments: z.string().nullish(),
+                    }),
+                  }),
+                )
+                .nullish(),
+            })
+            .nullish(),
+          finish_reason: z.string().nullish(),
+        }),
+      ),
+      usage: openaiCompatibleTokenUsageSchema,
+    }),
+    errorSchema,
+  ])

+ 28 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts

@@ -0,0 +1,28 @@
+import { z } from "zod/v4"
+
+export type OpenAICompatibleChatModelId = string
+
+export const openaiCompatibleProviderOptions = z.object({
+  /**
+   * A unique identifier representing your end-user, which can help the provider to
+   * monitor and detect abuse.
+   */
+  user: z.string().optional(),
+
+  /**
+   * Reasoning effort for reasoning models. Defaults to `medium`.
+   */
+  reasoningEffort: z.string().optional(),
+
+  /**
+   * Controls the verbosity of the generated text. Defaults to `medium`.
+   */
+  textVerbosity: z.string().optional(),
+
+  /**
+   * Copilot thinking_budget used for Anthropic models.
+   */
+  thinking_budget: z.number().optional(),
+})
+
+export type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>

+ 44 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts

@@ -0,0 +1,44 @@
+import type { SharedV2ProviderMetadata } from "@ai-sdk/provider"
+
+/**
+Extracts provider-specific metadata from API responses.
+Used to standardize metadata handling across different LLM providers while allowing
+provider-specific metadata to be captured.
+*/
+export type MetadataExtractor = {
+  /**
+   * Extracts provider metadata from a complete, non-streaming response.
+   *
+   * @param parsedBody - The parsed response JSON body from the provider's API.
+   *
+   * @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>
+
+  /**
+   * Creates an extractor for handling streaming responses. The returned object provides
+   * methods to process individual chunks and build the final metadata from the accumulated
+   * stream data.
+   *
+   * @returns An object with methods to process chunks and build metadata from a stream
+   */
+  createStreamExtractor: () => {
+    /**
+     * Process an individual chunk from the stream. Called for each chunk in the response stream
+     * to accumulate metadata throughout the streaming process.
+     *
+     * @param parsedChunk - The parsed JSON response chunk from the provider's API
+     */
+    processChunk(parsedChunk: unknown): void
+
+    /**
+     * Builds the metadata object after all chunks have been processed.
+     * Called at the end of the stream to generate the complete provider metadata.
+     *
+     * @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
+  }
+}

+ 87 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts

@@ -0,0 +1,87 @@
+import {
+  type LanguageModelV2CallOptions,
+  type LanguageModelV2CallWarning,
+  UnsupportedFunctionalityError,
+} from "@ai-sdk/provider"
+
+export function prepareTools({
+  tools,
+  toolChoice,
+}: {
+  tools: LanguageModelV2CallOptions["tools"]
+  toolChoice?: LanguageModelV2CallOptions["toolChoice"]
+}): {
+  tools:
+    | undefined
+    | Array<{
+        type: "function"
+        function: {
+          name: string
+          description: string | undefined
+          parameters: unknown
+        }
+      }>
+  toolChoice: { type: "function"; function: { name: string } } | "auto" | "none" | "required" | undefined
+  toolWarnings: LanguageModelV2CallWarning[]
+} {
+  // when the tools array is empty, change it to undefined to prevent errors:
+  tools = tools?.length ? tools : undefined
+
+  const toolWarnings: LanguageModelV2CallWarning[] = []
+
+  if (tools == null) {
+    return { tools: undefined, toolChoice: undefined, toolWarnings }
+  }
+
+  const openaiCompatTools: Array<{
+    type: "function"
+    function: {
+      name: string
+      description: string | undefined
+      parameters: unknown
+    }
+  }> = []
+
+  for (const tool of tools) {
+    if (tool.type === "provider-defined") {
+      toolWarnings.push({ type: "unsupported-tool", tool })
+    } else {
+      openaiCompatTools.push({
+        type: "function",
+        function: {
+          name: tool.name,
+          description: tool.description,
+          parameters: tool.inputSchema,
+        },
+      })
+    }
+  }
+
+  if (toolChoice == null) {
+    return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }
+  }
+
+  const type = toolChoice.type
+
+  switch (type) {
+    case "auto":
+    case "none":
+    case "required":
+      return { tools: openaiCompatTools, toolChoice: type, toolWarnings }
+    case "tool":
+      return {
+        tools: openaiCompatTools,
+        toolChoice: {
+          type: "function",
+          function: { name: toolChoice.toolName },
+        },
+        toolWarnings,
+      }
+    default: {
+      const _exhaustiveCheck: never = type
+      throw new UnsupportedFunctionalityError({
+        functionality: `tool choice type: ${_exhaustiveCheck}`,
+      })
+    }
+  }
+}

+ 1 - 1
packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts → packages/opencode/src/provider/sdk/copilot/copilot-provider.ts

@@ -1,6 +1,6 @@
 import type { LanguageModelV2 } from "@ai-sdk/provider"
 import type { LanguageModelV2 } from "@ai-sdk/provider"
-import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible"
 import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils"
 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"
 import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model"
 
 
 // Import the version or define it
 // Import the version or define it

+ 2 - 0
packages/opencode/src/provider/sdk/copilot/index.ts

@@ -0,0 +1,2 @@
+export { createOpenaiCompatible, openaiCompatible } from "./copilot-provider"
+export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./copilot-provider"

+ 27 - 0
packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts

@@ -0,0 +1,27 @@
+import { z, type ZodType } from "zod/v4"
+
+export const openaiCompatibleErrorDataSchema = z.object({
+  error: z.object({
+    message: z.string(),
+
+    // The additional information below is handled loosely to support
+    // OpenAI-compatible providers that have slightly different error
+    // responses:
+    type: z.string().nullish(),
+    param: z.any().nullish(),
+    code: z.union([z.string(), z.number()]).nullish(),
+  }),
+})
+
+export type OpenAICompatibleErrorData = z.infer<typeof openaiCompatibleErrorDataSchema>
+
+export type ProviderErrorStructure<T> = {
+  errorSchema: ZodType<T>
+  errorToMessage: (error: T) => string
+  isRetryable?: (response: Response, error?: T) => boolean
+}
+
+export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure<OpenAICompatibleErrorData> = {
+  errorSchema: openaiCompatibleErrorDataSchema,
+  errorToMessage: (data) => data.error.message,
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов