mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
Merge branch 'dev' into sqlite2
This commit is contained in:
@@ -23,6 +23,7 @@ runs:
|
||||
with:
|
||||
app-id: ${{ inputs.opencode-app-id }}
|
||||
private-key: ${{ inputs.opencode-app-secret }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Configure git user
|
||||
run: |
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -1,3 +1,7 @@
|
||||
### 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?
|
||||
|
||||
12
.github/workflows/beta.yml
vendored
12
.github/workflows/beta.yml
vendored
@@ -1,21 +1,15 @@
|
||||
name: beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
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
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
2
.github/workflows/generate.yml
vendored
2
.github/workflows/generate.yml
vendored
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
|
||||
167
.github/workflows/nix-hashes.yml
vendored
167
.github/workflows/nix-hashes.yml
vendored
@@ -21,11 +21,68 @@ on:
|
||||
- ".github/workflows/nix-hashes.yml"
|
||||
|
||||
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
|
||||
env:
|
||||
TITLE: node_modules hashes
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -33,108 +90,64 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Pull latest changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
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: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
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)"
|
||||
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
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
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
|
||||
|
||||
echo "All hashes computed:"
|
||||
cat "$HASH_FILE"
|
||||
|
||||
- name: Commit ${{ env.TITLE }} changes
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
- name: Commit changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
echo "Changes detected:"
|
||||
echo "$STATUS"
|
||||
git add "${FILES[@]}"
|
||||
git add "$HASH_FILE"
|
||||
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"
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
|
||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
|
||||
96
bun.lock
96
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -258,7 +258,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -266,25 +266,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@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/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@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-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@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/provider": "2.0.1",
|
||||
"@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",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -297,7 +297,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "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/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -365,7 +365,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -385,7 +385,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -396,7 +396,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -451,7 +451,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -462,7 +462,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -524,7 +524,7 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
@@ -564,23 +564,23 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "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/amazon-bedrock@3.0.74", "", { "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/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "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/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "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/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.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/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "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/google-vertex@3.0.97", "", { "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/google-vertex@3.0.98", "", { "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/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
@@ -596,11 +596,11 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "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/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "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/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "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/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -1240,7 +1240,7 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.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/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
@@ -1932,7 +1932,7 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "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/plugin-react@4.7.0", "", { "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": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@5.0.119", "", { "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": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "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": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "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/anthropic@2.0.58", "", { "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/eventstream-codec@4.2.8", "", { "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/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -4016,11 +4018,11 @@
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "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/anthropic@2.0.58", "", { "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/provider@2.0.0", "", { "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/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "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": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "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/anthropic@2.0.58", "", { "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/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "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=="],
|
||||
|
||||
|
||||
23
flake.nix
23
flake.nix
@@ -42,28 +42,15 @@
|
||||
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||
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
|
||||
{
|
||||
default = opencode;
|
||||
inherit opencode desktop;
|
||||
} // moduleUpdaters
|
||||
# Updater derivation with fakeHash - build fails and reveals correct hash
|
||||
node_modules_updater = node_modules.override {
|
||||
hash = pkgs.lib.fakeHash;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS6"),
|
||||
new sst.Secret("ZEN_MODELS7"),
|
||||
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_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
bun,
|
||||
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
|
||||
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
|
||||
rev ? "dirty",
|
||||
hash ?
|
||||
(lib.pipe ./hashes.json [
|
||||
@@ -16,6 +14,9 @@ let
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
];
|
||||
platform = stdenvNoCC.hostPlatform;
|
||||
bunCpu = if platform.isAarch64 then "arm64" else "x64";
|
||||
bunOs = if platform.isLinux then "linux" else "darwin";
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "opencode-node_modules";
|
||||
@@ -39,9 +40,7 @@ stdenvNoCC.mkDerivation {
|
||||
"SOCKS_SERVER"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
];
|
||||
nativeBuildInputs = [ bun ];
|
||||
|
||||
dontConfigure = true;
|
||||
|
||||
@@ -63,10 +62,8 @@ stdenvNoCC.mkDerivation {
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out
|
||||
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "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-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
||||
271
packages/app/e2e/actions.ts
Normal file
271
packages/app/e2e/actions.ts
Normal file
@@ -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="e2e@example.com" 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)
|
||||
}
|
||||
}
|
||||
@@ -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 }) => {
|
||||
await page.goto("/")
|
||||
@@ -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 }) => {
|
||||
await page.goto(dirPath(directory))
|
||||
11
packages/app/e2e/app/palette.spec.ts
Normal file
11
packages/app/e2e/app/palette.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
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(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()
|
||||
|
||||
16
packages/app/e2e/app/session.spec.ts
Normal file
16
packages/app/e2e/app/session.spec.ts
Normal file
@@ -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
packages/app/e2e/app/titlebar-history.spec.ts
Normal file
42
packages/app/e2e/app/titlebar-history.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
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()
|
||||
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)
|
||||
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
const sep = process.platform === "win32" ? "\\" : "/"
|
||||
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()
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
@@ -29,54 +31,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
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) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 }) => {
|
||||
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 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()
|
||||
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 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.keyboard.type("/model")
|
||||
@@ -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
packages/app/e2e/projects/project-edit.spec.ts
Normal file
52
packages/app/e2e/projects/project-edit.spec.ts
Normal file
@@ -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
packages/app/e2e/projects/projects-close.spec.ts
Normal file
70
packages/app/e2e/projects/projects-close.spec.ts
Normal file
@@ -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
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
34
packages/app/e2e/projects/projects-switch.spec.ts
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }) => {
|
||||
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({
|
||||
sessionID,
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
await expect
|
||||
.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
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await gotoSession(sessionID)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const contextButton = page
|
||||
.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"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -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.setTimeout(120_000)
|
||||
35
packages/app/e2e/selectors.ts
Normal file
35
packages/app/e2e/selectors.ts
Normal file
@@ -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}"]`
|
||||
@@ -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
packages/app/e2e/session/session.spec.ts
Normal file
115
packages/app/e2e/session/session.spec.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
packages/app/e2e/settings/settings-language.spec.ts
Normal file
28
packages/app/e2e/settings/settings-language.spec.ts
Normal file
@@ -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")
|
||||
})
|
||||
@@ -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 }) => {
|
||||
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 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)
|
||||
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
packages/app/e2e/settings/settings.spec.ts
Normal file
14
packages/app/e2e/settings/settings.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 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 {
|
||||
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()
|
||||
await expect(target).toBeVisible()
|
||||
14
packages/app/e2e/sidebar/sidebar.spec.ts
Normal file
14
packages/app/e2e/sidebar/sidebar.spec.ts
Normal file
@@ -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/)
|
||||
})
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -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 }) => {
|
||||
await gotoSession()
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { modelVariantCycleSelector } from "./utils"
|
||||
import { modelVariantCycleSelector } from "./selectors"
|
||||
|
||||
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
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) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
|
||||
@@ -167,7 +167,7 @@ export function SessionHeader() {
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm w-[60px] h-[24px]",
|
||||
class: "rounded-sm h-[24px] px-3",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
|
||||
@@ -148,6 +148,7 @@ export const SettingsGeneral: Component = () => {
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
|
||||
@@ -2285,6 +2285,8 @@ export default function Layout(props: ParentProps) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={projectName()}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"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(),
|
||||
@@ -2335,6 +2337,8 @@ export default function Layout(props: ParentProps) {
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
data-action="project-close-hover"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
aria-label={language.t("common.close")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -2577,6 +2581,8 @@ export default function Layout(props: ParentProps) {
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
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"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
@@ -2604,7 +2610,11 @@ export default function Layout(props: ParentProps) {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<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.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -2814,6 +2824,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"relative shrink-0": true,
|
||||
@@ -2873,6 +2884,7 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@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(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenData } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 8
|
||||
const PARTS = 10
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.join("=")
|
||||
@@ -27,6 +28,6 @@ const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenData } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 8
|
||||
const PARTS = 10
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.join("=")
|
||||
@@ -27,6 +28,6 @@ const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -7,18 +7,20 @@ import { ZenData } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
const PARTS = 8
|
||||
const PARTS = 10
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const lines = models.split("\n")
|
||||
const oldValues = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}`))
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.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
|
||||
@@ -38,6 +40,6 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
|
||||
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)
|
||||
|
||||
@@ -75,7 +75,9 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS5.value +
|
||||
Resource.ZEN_MODELS6.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)
|
||||
})
|
||||
|
||||
8
packages/console/core/sst-env.d.ts
vendored
8
packages/console/core/sst-env.d.ts
vendored
@@ -133,6 +133,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -161,6 +165,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
8
packages/console/function/sst-env.d.ts
vendored
8
packages/console/function/sst-env.d.ts
vendored
@@ -133,6 +133,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -161,6 +165,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
8
packages/console/resource/sst-env.d.ts
vendored
8
packages/console/resource/sst-env.d.ts
vendored
@@ -133,6 +133,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -161,6 +165,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
8
packages/enterprise/sst-env.d.ts
vendored
8
packages/enterprise/sst-env.d.ts
vendored
@@ -133,6 +133,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -161,6 +165,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.45"
|
||||
version = "1.1.48"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.45/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
8
packages/function/sst-env.d.ts
vendored
8
packages/function/sst-env.d.ts
vendored
@@ -133,6 +133,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS10": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS2": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -161,6 +165,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS9": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.45",
|
||||
"version": "1.1.48",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -53,25 +53,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@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/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@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-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@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/provider": "2.0.1",
|
||||
"@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",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "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/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -14,11 +14,11 @@ process.chdir(dir)
|
||||
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? 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(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
|
||||
@@ -37,7 +37,6 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
),
|
||||
)
|
||||
|
||||
/*
|
||||
const tasks = Object.entries(binaries).map(async ([name]) => {
|
||||
if (process.platform !== "win32") {
|
||||
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 tagFlags = tags.flatMap((t) => ["-t", t])
|
||||
await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
|
||||
*/
|
||||
|
||||
// registries
|
||||
if (!Script.preview) {
|
||||
@@ -65,7 +63,6 @@ if (!Script.preview) {
|
||||
|
||||
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
|
||||
|
||||
/*
|
||||
// arch
|
||||
const binaryPkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
@@ -179,7 +176,6 @@ if (!Script.preview) {
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AcpCommand = cmd({
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
|
||||
@@ -104,6 +104,7 @@ export function tui(input: {
|
||||
args: Args
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
onExit?: () => Promise<void>
|
||||
}) {
|
||||
@@ -130,6 +131,7 @@ export function tui(input: {
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
|
||||
@@ -19,21 +19,34 @@ export const AttachCommand = cmd({
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("password", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
let directory = args.dir
|
||||
if (args.dir) {
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
directory = process.cwd()
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// 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({
|
||||
url: args.url,
|
||||
args: { sessionID: args.session },
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -345,8 +345,9 @@ export function Autocomplete(props: {
|
||||
const results: AutocompleteOption[] = [...command.slashes()]
|
||||
|
||||
for (const serverCommand of sync.data.command) {
|
||||
const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
|
||||
results.push({
|
||||
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
|
||||
display: "/" + serverCommand.name + label,
|
||||
description: serverCommand.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + serverCommand.name + " "
|
||||
|
||||
@@ -100,7 +100,7 @@ const TIPS = [
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
|
||||
"Define custom formatter commands with file extensions in config",
|
||||
"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",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
|
||||
"Use plugins to send OS notifications when sessions complete",
|
||||
|
||||
@@ -9,13 +9,20 @@ export type EventSource = {
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
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 sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import PROMPT_REVIEW from "./template/review.txt"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
export namespace Command {
|
||||
export const Event = {
|
||||
@@ -26,7 +27,7 @@ export namespace Command {
|
||||
description: z.string().optional(),
|
||||
agent: 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
|
||||
// https://zod.dev/v4/changelog?id=zfunction
|
||||
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())) {
|
||||
result[name] = {
|
||||
name,
|
||||
mcp: true,
|
||||
source: "mcp",
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
// 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
|
||||
})
|
||||
|
||||
|
||||
@@ -1078,29 +1078,6 @@ export namespace Config {
|
||||
.optional(),
|
||||
experimental: z
|
||||
.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(),
|
||||
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
|
||||
openTelemetry: z
|
||||
|
||||
4
packages/opencode/src/env/index.ts
vendored
4
packages/opencode/src/env/index.ts
vendored
@@ -2,7 +2,9 @@ import { Instance } from "../project/instance"
|
||||
|
||||
export namespace Env {
|
||||
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) {
|
||||
|
||||
@@ -214,8 +214,8 @@ export namespace Ripgrep {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
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.glob) {
|
||||
for (const g of input.glob) {
|
||||
@@ -381,7 +381,7 @@ export namespace Ripgrep {
|
||||
follow?: boolean
|
||||
}) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
|
||||
if (input.follow !== false) args.push("--follow")
|
||||
if (input.follow) args.push("--follow")
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export namespace Flag {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
|
||||
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_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_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
@@ -77,3 +78,14 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
||||
enumerable: true,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
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(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
|
||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
|
||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
|
||||
import { createXai } from "@ai-sdk/xai"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { createGroq } from "@ai-sdk/groq"
|
||||
@@ -195,11 +195,13 @@ export namespace Provider {
|
||||
|
||||
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 envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
|
||||
const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
|
||||
if (envToken) return envToken
|
||||
if (auth?.type === "api") {
|
||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
|
||||
return auth.key
|
||||
}
|
||||
return undefined
|
||||
@@ -376,17 +378,19 @@ export namespace Provider {
|
||||
},
|
||||
"sap-ai-core": async () => {
|
||||
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 envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
|
||||
const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
|
||||
if (envAICoreServiceKey) return envAICoreServiceKey
|
||||
if (auth?.type === "api") {
|
||||
Env.set("AICORE_SERVICE_KEY", auth.key)
|
||||
process.env.AICORE_SERVICE_KEY = auth.key
|
||||
return auth.key
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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({
|
||||
name: model.providerID,
|
||||
...options,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
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 { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model"
|
||||
import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model"
|
||||
|
||||
// Import the version or define it
|
||||
2
packages/opencode/src/provider/sdk/copilot/index.ts
Normal file
2
packages/opencode/src/provider/sdk/copilot/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createOpenaiCompatible, openaiCompatible } from "./copilot-provider"
|
||||
export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./copilot-provider"
|
||||
@@ -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,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user