From f898f8b7b086a2bf424b47e4b7a629a5c20be316 Mon Sep 17 00:00:00 2001 From: ikeda-tomoya-swx Date: Sun, 18 Jan 2026 08:34:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Kiro=E3=83=97=E3=83=AD=E3=83=90?= =?UTF-8?q?=E3=82=A4=E3=83=80=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88?= =?UTF-8?q?Fake=20Reasoning=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/nix-desktop.yml | 4 +- .github/workflows/update-nix-hashes.yml | 202 +----- .gitignore | 1 + STATS.md | 1 - github/README.md | 2 - nix/hashes.json | 2 +- nix/scripts/update-hashes.sh | 119 ++++ packages/app/src/app.tsx | 2 +- .../app/src/components/dialog-select-file.tsx | 2 +- packages/app/src/context/command.tsx | 4 +- packages/app/src/context/global-sync.tsx | 2 - packages/app/src/pages/layout.tsx | 19 +- packages/desktop/src/index.tsx | 17 +- packages/opencode/.gitignore | 1 + packages/opencode/CLAUDE.md | 255 ++++++++ packages/opencode/src/cli/cmd/auth.ts | 24 +- packages/opencode/src/cli/cmd/mcp.ts | 6 + .../src/cli/cmd/tui/context/local.tsx | 9 - packages/opencode/src/cli/cmd/web.ts | 6 +- packages/opencode/src/config/config.ts | 4 + packages/opencode/src/mcp/index.ts | 27 +- packages/opencode/src/mcp/oauth-callback.ts | 34 +- packages/opencode/src/mcp/oauth-provider.ts | 24 + packages/opencode/src/plugin/index.ts | 3 +- packages/opencode/src/plugin/kiro.ts | 183 ++++++ packages/opencode/src/provider/provider.ts | 229 ++++++- .../src/provider/sdk/kiro/src/converters.ts | 514 +++++++++++++++ .../src/provider/sdk/kiro/src/index.ts | 2 + .../sdk/kiro/src/kiro-language-model.ts | 431 ++++++++++++ .../provider/sdk/kiro/src/kiro-provider.ts | 36 + .../provider/sdk/kiro/src/model-resolver.ts | 15 + .../src/provider/sdk/kiro/src/streaming.ts | 614 ++++++++++++++++++ packages/opencode/src/provider/transform.ts | 120 ++-- packages/opencode/src/server/mdns.ts | 4 +- packages/opencode/src/server/server.ts | 2 +- .../opencode/test/mcp/oauth-callback.test.ts | 75 +++ .../opencode/test/provider/transform.test.ts | 128 +--- packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/sdk/openapi.json | 4 + packages/ui/src/components/list.tsx | 4 +- packages/ui/src/components/logo.tsx | 15 - packages/web/src/content/docs/agents.mdx | 12 +- packages/web/src/content/docs/commands.mdx | 24 +- packages/web/src/content/docs/config.mdx | 11 +- .../web/src/content/docs/custom-tools.mdx | 16 +- packages/web/src/content/docs/ecosystem.mdx | 21 +- packages/web/src/content/docs/github.mdx | 2 - packages/web/src/content/docs/modes.mdx | 12 +- packages/web/src/content/docs/permissions.mdx | 2 +- packages/web/src/content/docs/plugins.mdx | 24 +- packages/web/src/content/docs/providers.mdx | 27 - packages/web/src/content/docs/skills.mdx | 10 +- 52 files changed, 2778 insertions(+), 533 deletions(-) create mode 100755 nix/scripts/update-hashes.sh create mode 100644 packages/opencode/CLAUDE.md create mode 100644 packages/opencode/src/plugin/kiro.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/converters.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/index.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/kiro-language-model.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/kiro-provider.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/model-resolver.ts create mode 100644 packages/opencode/src/provider/sdk/kiro/src/streaming.ts create mode 100644 packages/opencode/test/mcp/oauth-callback.test.ts diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml index 3d7c4803133..01cfaed78b4 100644 --- a/.github/workflows/nix-desktop.yml +++ b/.github/workflows/nix-desktop.yml @@ -9,7 +9,6 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" - - ".github/workflows/nix-desktop.yml" pull_request: paths: - "flake.nix" @@ -17,7 +16,6 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" - - ".github/workflows/nix-desktop.yml" workflow_dispatch: jobs: @@ -28,7 +26,7 @@ jobs: os: - blacksmith-4vcpu-ubuntu-2404 - blacksmith-4vcpu-ubuntu-2404-arm - - macos-15-intel + - macos-15 - macos-latest runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml index f80a57d25d8..19373f748f2 100644 --- a/.github/workflows/update-nix-hashes.yml +++ b/.github/workflows/update-nix-hashes.yml @@ -10,13 +10,11 @@ on: - "bun.lock" - "package.json" - "packages/*/package.json" - - ".github/workflows/update-nix-hashes.yml" pull_request: paths: - "bun.lock" - "package.json" - "packages/*/package.json" - - ".github/workflows/update-nix-hashes.yml" jobs: update-flake: @@ -27,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -45,9 +43,9 @@ jobs: - name: Update ${{ env.TITLE }} run: | set -euo pipefail - echo "Updating $TITLE..." + echo "📦 Updating $TITLE..." nix flake update - echo "$TITLE updated successfully" + echo "✅ $TITLE updated successfully" - name: Commit ${{ env.TITLE }} changes env: @@ -55,7 +53,7 @@ jobs: run: | set -euo pipefail - echo "Checking for changes in tracked files..." + echo "🔍 Checking for changes in tracked files..." summarize() { local status="$1" @@ -73,29 +71,29 @@ jobs: FILES=(flake.lock flake.nix) STATUS="$(git status --short -- "${FILES[@]}" || true)" if [ -z "$STATUS" ]; then - echo "No changes detected." + echo "✅ No changes detected." summarize "no changes" exit 0 fi - echo "Changes detected:" + echo "📝 Changes detected:" echo "$STATUS" - echo "Staging files..." + echo "🔗 Staging files..." git add "${FILES[@]}" - echo "Committing changes..." + echo "💾 Committing changes..." git commit -m "Update $TITLE" - echo "Changes committed" + echo "✅ Changes committed" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - echo "Pulling latest from branch: $BRANCH" - git pull --rebase --autostash origin "$BRANCH" - echo "Pushing changes to branch: $BRANCH" + echo "🌳 Pulling latest from branch: $BRANCH" + git pull --rebase origin "$BRANCH" + echo "🚀 Pushing changes to branch: $BRANCH" git push origin HEAD:"$BRANCH" - echo "Changes pushed successfully" + echo "✅ Changes pushed successfully" summarize "committed $(git rev-parse --short HEAD)" - compute-node-modules-hash: + update-node-modules-hash: needs: update-flake if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository strategy: @@ -113,10 +111,11 @@ jobs: runs-on: ${{ matrix.host }} env: SYSTEM: ${{ matrix.system }} + TITLE: node_modules hash (${{ matrix.system }}) steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -126,104 +125,6 @@ jobs: - name: Setup Nix uses: nixbuild/nix-quick-install-action@v34 - - name: Compute node_modules hash - run: | - set -euo pipefail - - DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - HASH_FILE="nix/hashes.json" - OUTPUT_FILE="hash-${SYSTEM}.txt" - - export NIX_KEEP_OUTPUTS=1 - export NIX_KEEP_DERIVATIONS=1 - - BUILD_LOG=$(mktemp) - TMP_JSON=$(mktemp) - trap 'rm -f "$BUILD_LOG" "$TMP_JSON"' EXIT - - if [ ! -f "$HASH_FILE" ]; then - mkdir -p "$(dirname "$HASH_FILE")" - echo '{"nodeModules":{}}' > "$HASH_FILE" - fi - - # Set dummy hash to force nix to rebuild and reveal correct hash - jq --arg system "$SYSTEM" --arg value "$DUMMY" \ - '.nodeModules = (.nodeModules // {}) | .nodeModules[$system] = $value' "$HASH_FILE" > "$TMP_JSON" - mv "$TMP_JSON" "$HASH_FILE" - - MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" - DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" - - echo "Building node_modules for ${SYSTEM} to discover correct hash..." - echo "Attempting to realize derivation: ${DRV_PATH}" - REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true) - - BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true) - CORRECT_HASH="" - - if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then - echo "Realized node_modules output: $BUILD_PATH" - CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true) - fi - - # Try to extract hash from build log - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" - fi - - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" - fi - - # Try to hash from kept failed build directory - if [ -z "$CORRECT_HASH" ]; then - KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1 || true) - if [ -z "$KEPT_DIR" ]; then - KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1 || true) - fi - - if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then - HASH_PATH="$KEPT_DIR" - [ -d "$KEPT_DIR/build" ] && HASH_PATH="$KEPT_DIR/build" - - if [ -d "$HASH_PATH/node_modules" ]; then - CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) - fi - fi - fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - cat "$BUILD_LOG" - exit 1 - fi - - echo "$CORRECT_HASH" > "$OUTPUT_FILE" - echo "Hash for ${SYSTEM}: $CORRECT_HASH" - - - name: Upload hash artifact - uses: actions/upload-artifact@v6 - with: - name: hash-${{ matrix.system }} - path: hash-${{ matrix.system }}.txt - retention-days: 1 - - commit-node-modules-hashes: - needs: compute-node-modules-hash - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - TITLE: node_modules hashes - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - 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 }} - - name: Configure git run: | git config --global user.email "action@github.com" @@ -234,57 +135,14 @@ jobs: TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} run: | BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" - - - name: Download all hash artifacts - uses: actions/download-artifact@v7 - with: - pattern: hash-* - merge-multiple: true + git pull origin "$BRANCH" - - name: Merge hashes into hashes.json + - name: Update ${{ env.TITLE }} run: | set -euo pipefail - - HASH_FILE="nix/hashes.json" - - if [ ! -f "$HASH_FILE" ]; then - mkdir -p "$(dirname "$HASH_FILE")" - echo '{"nodeModules":{}}' > "$HASH_FILE" - fi - - echo "Merging hashes into ${HASH_FILE}..." - - shopt -s nullglob - files=(hash-*.txt) - if [ ${#files[@]} -eq 0 ]; then - echo "No hash files found, nothing to update" - exit 0 - fi - - EXPECTED_SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" - for sys in $EXPECTED_SYSTEMS; do - if [ ! -f "hash-${sys}.txt" ]; then - echo "WARNING: Missing hash file for $sys" - fi - done - - for f in "${files[@]}"; do - system="${f#hash-}" - system="${system%.txt}" - hash=$(cat "$f") - if [ -z "$hash" ]; then - echo "WARNING: Empty hash for $system, skipping" - continue - fi - echo " $system: $hash" - jq --arg sys "$system" --arg h "$hash" \ - '.nodeModules = (.nodeModules // {}) | .nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp" - mv "${HASH_FILE}.tmp" "$HASH_FILE" - done - - echo "All hashes merged:" - cat "$HASH_FILE" + echo "🔄 Updating $TITLE..." + nix/scripts/update-hashes.sh + echo "✅ $TITLE updated successfully" - name: Commit ${{ env.TITLE }} changes env: @@ -292,8 +150,7 @@ jobs: run: | set -euo pipefail - HASH_FILE="nix/hashes.json" - echo "Checking for changes..." + echo "🔍 Checking for changes in tracked files..." summarize() { local status="$1" @@ -309,22 +166,27 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" } - FILES=("$HASH_FILE") + FILES=(nix/hashes.json) STATUS="$(git status --short -- "${FILES[@]}" || true)" if [ -z "$STATUS" ]; then - echo "No changes detected." + echo "✅ No changes detected." summarize "no changes" exit 0 fi - echo "Changes detected:" + echo "📝 Changes detected:" echo "$STATUS" + echo "🔗 Staging files..." git add "${FILES[@]}" + echo "💾 Committing changes..." git commit -m "Update $TITLE" + echo "✅ Changes committed" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" + echo "🌳 Pulling latest from branch: $BRANCH" + git pull --rebase origin "$BRANCH" + echo "🚀 Pushing changes to branch: $BRANCH" git push origin HEAD:"$BRANCH" - echo "Changes pushed successfully" + echo "✅ Changes pushed successfully" summarize "committed $(git rev-parse --short HEAD)" diff --git a/.gitignore b/.gitignore index 78a77f81982..fe108a0edd4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ target opencode-dev logs/ *.bun-build +.opencode/ diff --git a/STATS.md b/STATS.md index 9a665612b14..e09c57e8f41 100644 --- a/STATS.md +++ b/STATS.md @@ -202,4 +202,3 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | -| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | diff --git a/github/README.md b/github/README.md index 17b24ffb1d6..8238bdc42aa 100644 --- a/github/README.md +++ b/github/README.md @@ -91,10 +91,8 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 - use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/nix/hashes.json b/nix/hashes.json index 16a1c1f398b..41fb0260278 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,7 +1,7 @@ { "nodeModules": { "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", - "aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=", + "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" } diff --git a/nix/scripts/update-hashes.sh b/nix/scripts/update-hashes.sh new file mode 100755 index 00000000000..1e294fe4fb4 --- /dev/null +++ b/nix/scripts/update-hashes.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +SYSTEM=${SYSTEM:-x86_64-linux} +DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json} +HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE} + +if [ ! -f "$HASH_FILE" ]; then + cat >"$HASH_FILE" </dev/null 2>&1; then + if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then + git add -N "$HASH_FILE" >/dev/null 2>&1 || true + fi +fi + +export DUMMY +export NIX_KEEP_OUTPUTS=1 +export NIX_KEEP_DERIVATIONS=1 + +cleanup() { + rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}" +} + +trap cleanup EXIT + +write_node_modules_hash() { + local value="$1" + local system="${2:-$SYSTEM}" + local temp + temp=$(mktemp) + + if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then + jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp" + else + jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp" + fi + + mv "$temp" "$HASH_FILE" +} + +TARGET="packages.${SYSTEM}.default" +MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" +CORRECT_HASH="" + +DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" + +echo "Setting dummy node_modules outputHash for ${SYSTEM}..." +write_node_modules_hash "$DUMMY" + +BUILD_LOG=$(mktemp) +JSON_OUTPUT=$(mktemp) + +echo "Building node_modules for ${SYSTEM} to discover correct outputHash..." +echo "Attempting to realize derivation: ${DRV_PATH}" +REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true) + +BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true) +if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then + echo "Realized node_modules output: $BUILD_PATH" + CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true) +fi + +if [ -z "$CORRECT_HASH" ]; then + CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" + + if [ -z "$CORRECT_HASH" ]; then + CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" + fi + + if [ -z "$CORRECT_HASH" ]; then + echo "Searching for kept failed build directory..." + KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1) + + if [ -z "$KEPT_DIR" ]; then + KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1) + fi + + if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then + echo "Found kept build directory: $KEPT_DIR" + if [ -d "$KEPT_DIR/build" ]; then + HASH_PATH="$KEPT_DIR/build" + else + HASH_PATH="$KEPT_DIR" + fi + + echo "Attempting to hash: $HASH_PATH" + ls -la "$HASH_PATH" || true + + if [ -d "$HASH_PATH/node_modules" ]; then + CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) + echo "Computed hash from kept build: $CORRECT_HASH" + fi + fi + fi +fi + +if [ -z "$CORRECT_HASH" ]; then + echo "Failed to determine correct node_modules hash for ${SYSTEM}." + echo "Build log:" + cat "$BUILD_LOG" + exit 1 +fi + +write_node_modules_hash "$CORRECT_HASH" + +jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null + +echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH" + +rm -f "$BUILD_LOG" +unset BUILD_LOG diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d03d10d0ea7..d0678dc5369 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,7 +29,7 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
+const Loading = () =>
Loading...
declare global { interface Window { diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 0e8d69628bb..432e531e192 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -149,7 +149,7 @@ export function DialogSelectFile() { +
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d8dc13e2344..a93ffc02454 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,6 +1,5 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -123,7 +122,6 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { - const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -167,7 +165,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended() || dialog.active) return + if (suspended()) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 96f8c63eab2..74641a0a243 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,8 +379,6 @@ function createGlobalSync() { }), ) } - if (event.properties.info.parentID) break - setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 25c94554069..bc62c70232f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions) }) @@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) { const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) { 0 && props.notify}>
workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1349,7 +1349,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1358,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions) .slice(0, 2) } @@ -1383,8 +1383,7 @@ export default function Layout(props: ParentProps) {
-
{displayName(props.project)}
-
Recent sessions
+
Recent sessions
workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) + .filter((session) => !session.parentID) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 0d9e383790a..7a46ba8cde0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" -import { Splash } from "@opencode-ai/ui/logo" +import { Logo } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" @@ -26,18 +26,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } -const isWindows = ostype() === "windows" -if (isWindows) { - const originalGetComputedStyle = window.getComputedStyle - window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { - if (!(elt instanceof Element)) { - // WebView2 can call into Floating UI with non-elements; fall back to a safe element. - return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) - } - return originalGetComputedStyle(elt, pseudoElt ?? undefined) - }) as typeof window.getComputedStyle -} - let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ @@ -357,7 +345,8 @@ function ServerGate(props: { children: (data: Accessor) => JSX. when={serverData.state !== "pending" && serverData()} fallback={
- + +
Initializing...
} > diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index e057ca61f94..a331e347f91 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -2,3 +2,4 @@ research dist gen app.log +reference/ diff --git a/packages/opencode/CLAUDE.md b/packages/opencode/CLAUDE.md new file mode 100644 index 00000000000..228dc05bf60 --- /dev/null +++ b/packages/opencode/CLAUDE.md @@ -0,0 +1,255 @@ +# OpenCode 開発ガイド + +## ビルド + +```bash +cd /Users/ikedatomoya/Documents/work/opencode/packages/opencode +bun run build +``` + +ビルド結果は `dist/` ディレクトリに出力される: + +``` +dist/ +├── opencode-darwin-arm64/ # macOS Apple Silicon +│ └── bin/opencode +├── opencode-darwin-x64/ # macOS Intel +├── opencode-linux-arm64/ # Linux ARM +├── opencode-linux-x64/ # Linux x64 +└── opencode-windows-x64/ # Windows +``` + +## インストール(開発版) + +### macOS Apple Silicon の場合 + +ビルド後、開発版バイナリを `~/.local/bin/` にコピー: + +```bash +# コピー元: dist/opencode-darwin-arm64/bin/opencode +# コピー先: ~/.local/bin/opencode +cp dist/opencode-darwin-arm64/bin/opencode ~/.local/bin/opencode +``` + +### macOS Intel の場合 + +```bash +cp dist/opencode-darwin-x64/bin/opencode ~/.local/bin/opencode +``` + +### ビルド&インストール(ワンライナー) + +```bash +cd /Users/ikedatomoya/Documents/work/opencode/packages/opencode && \ +bun run build && \ +cp dist/opencode-darwin-arm64/bin/opencode ~/.local/bin/opencode +``` + +## 起動 + +### 開発版(インストール済み) + +```bash +~/.local/bin/opencode +``` + +### 開発モード(ソースから直接実行) + +```bash +cd /Users/ikedatomoya/Documents/work/opencode/packages/opencode +bun run dev +``` + +--- + +## デバッグ + +### ログファイルの場所 + +OpenCodeのログは以下に出力される: + +| 種類 | パス | +|------|------| +| メインログ | `~/Library/Application Support/opencode/log/YYYY-MM-DDTHHMMSS.log` | +| 開発モードログ | `~/Library/Application Support/opencode/log/dev.log` | +| Kiroデバッグログ | `/tmp/kiro-debug.log` | + +### ログ確認コマンド + +```bash +# 最新のログファイルを確認 +ls -lt ~/Library/Application\ Support/opencode/log/ | head -5 + +# 最新のログファイルをリアルタイム監視 +tail -f "$(ls -t ~/Library/Application\ Support/opencode/log/*.log | head -1)" + +# 開発モードのログを監視 +tail -f ~/Library/Application\ Support/opencode/log/dev.log + +# エラーのみをフィルタ +grep -i error "$(ls -t ~/Library/Application\ Support/opencode/log/*.log | head -1)" + +# 特定のagent(build, title等)のログをフィルタ +grep "agent=build" "$(ls -t ~/Library/Application\ Support/opencode/log/*.log | head -1)" +``` + +### ログのフォーマット + +``` +INFO 2026-01-17T09:14:32 +123ms service=session agent=build stream +ERROR 2026-01-17T09:14:35 +3000ms service=session agent=build error=... +``` + +| フィールド | 説明 | +|-----------|------| +| `INFO/ERROR/WARN/DEBUG` | ログレベル | +| `2026-01-17T09:14:32` | タイムスタンプ | +| `+123ms` | 前のログからの経過時間 | +| `service=xxx` | サービス名 | +| `agent=xxx` | エージェント名(build, title等) | + +### Kiroプロバイダーのデバッグ + +Kiro専用のデバッグログは `/tmp/kiro-debug.log` に出力される: + +```bash +# リアルタイム監視 +tail -f /tmp/kiro-debug.log + +# ログをクリアして新しく開始 +echo "" > /tmp/kiro-debug.log && tail -f /tmp/kiro-debug.log +``` + +#### Kiroデバッグログの内容 + +``` +[2026-01-17T09:14:32.123Z] URL: https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse +Prompt structure: [0] role=system, [1] role=user, [2] role=assistant, [3] role=user +Payload: { + "conversationState": { + "chatTriggerType": "MANUAL", + "conversationId": "...", + "currentMessage": {...}, + "history": [...] + } +} +``` + +| 項目 | 説明 | +|------|------| +| URL | APIエンドポイント | +| Prompt structure | AI SDKから渡されたメッセージのrole一覧 | +| Payload | Kiro APIに送信される実際のリクエスト | + +### よくある問題 + +#### 400 Bad Request(Kiro) + +**原因**: Kiro APIはuser/assistantの交互配置を要求する + +**確認方法**: +```bash +grep "Prompt structure" /tmp/kiro-debug.log +``` + +**問題のあるパターン**: +``` +Prompt structure: [0] role=system, [1] role=user, [2] role=assistant, [3] role=assistant +``` +連続したassistant(index 2と3)がある場合、`converters.ts`のマージ処理が正しく動作していない。 + +**修正箇所**: `src/provider/sdk/kiro/src/converters.ts` + +#### 認証エラー(Kiro) + +```bash +# Kiro CLIで再認証 +kiro login + +# 認証情報の確認 +ls -la ~/Library/Application\ Support/kiro-cli/data.sqlite3 + +# 認証情報の中身を確認(SQLite) +sqlite3 ~/Library/Application\ Support/kiro-cli/data.sqlite3 ".tables" +``` + +#### ストリーミングエラー(Kiro) + +AWS Event Streamのパースエラーの場合、`streaming.ts`を確認: + +```bash +# レスポンスの生データを確認するには、kiro-language-model.tsにログを追加 +``` + +--- + +## Kiroプロバイダー + +Kiro(AWS)のサブスクリプションを使ってOpenCodeを利用可能。 + +### 前提条件 + +1. Kiro CLIがインストールされていること +2. `kiro login` で認証済みであること + +認証情報は `~/Library/Application Support/kiro-cli/data.sqlite3` に保存される。 + +### 実装構成 + +``` +src/provider/sdk/kiro/src/ +├── index.ts # export { createKiro } +├── kiro-provider.ts # createKiro() ファクトリ +├── kiro-language-model.ts # LanguageModelV2 実装(デバッグログ出力) +├── converters.ts # AI SDK → Kiro 形式変換(メッセージマージ処理) +├── streaming.ts # AWS Event Stream パース +└── model-resolver.ts # モデルID正規化 +``` + +### 利用可能なモデル + +- `claude-sonnet-4-5` (Claude Sonnet 4.5) +- `claude-opus-4-5` (Claude Opus 4.5) +- `claude-haiku-4-5` (Claude Haiku 4.5) +- `claude-sonnet-4` (Claude Sonnet 4) +- `claude-3-7-sonnet` (Claude 3.7 Sonnet) + +### テスト方法 + +```bash +# 1. ビルド&インストール +cd /Users/ikedatomoya/Documents/work/opencode/packages/opencode +bun run build +cp dist/opencode-darwin-arm64/bin/opencode ~/.local/bin/opencode + +# 2. デバッグログをクリア +echo "" > /tmp/kiro-debug.log + +# 3. OpenCodeを起動してKiroモデルを選択 +~/.local/bin/opencode + +# 4. 別ターミナルでログを監視 +tail -f /tmp/kiro-debug.log +``` + +### リファレンス + +実装の参考: `reference/kiro-gateway/kiro/` (kiro-gateway プロジェクト) + +--- + +## ディレクトリ構成 + +``` +~/Library/Application Support/opencode/ # XDG_DATA_HOME/opencode +├── log/ # ログファイル +│ ├── dev.log # 開発モードログ +│ └── 2026-01-17T091432.log # 通常ログ(タイムスタンプ) +└── bin/ # ダウンロードしたバイナリ + +~/Library/Caches/opencode/ # XDG_CACHE_HOME/opencode +└── ... # キャッシュファイル + +~/.config/opencode/ # XDG_CONFIG_HOME/opencode +└── ... # 設定ファイル +``` diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c71..aaaf20bf731 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -273,9 +273,26 @@ export const AuthLoginCommand = cmd({ anthropic: 1, "github-copilot": 2, openai: 3, - google: 4, - openrouter: 5, - vercel: 6, + kiro: 4, + google: 5, + openrouter: 6, + vercel: 7, + } + + // Add plugin-based providers that have auth methods + const plugins = await Plugin.list() + for (const plugin of plugins) { + if (plugin.auth?.provider && !providers[plugin.auth.provider]) { + const providerName = { + kiro: "Kiro (AWS)", + }[plugin.auth.provider] ?? plugin.auth.provider + providers[plugin.auth.provider] = { + id: plugin.auth.provider, + name: providerName, + env: [], + models: {}, + } + } } let provider = await prompts.autocomplete({ message: "Select provider", @@ -295,6 +312,7 @@ export const AuthLoginCommand = cmd({ opencode: "recommended", anthropic: "Claude Max or API key", openai: "ChatGPT Plus/Pro or API key", + kiro: "Use existing Kiro CLI login", }[x.id], })), ), diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 95719215e32..fedad92856f 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -6,6 +6,7 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" +import { McpOAuthCallback } from "../../mcp/oauth-callback" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" @@ -682,6 +683,10 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + + // Start callback server + await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -689,6 +694,7 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d058ce54fb3..63f1d9743bf 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,16 +113,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) - const state = { - pending: false, - } function save() { - if (!modelStore.ready) { - state.pending = true - return - } - state.pending = false Bun.write( file, JSON.stringify({ @@ -143,7 +135,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) - if (state.pending) save() }) const args = useArgs() diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 5fa2bb42640..2c207ecc2f2 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,11 +60,7 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println( - UI.Style.TEXT_INFO_BOLD + " mDNS: ", - UI.Style.TEXT_NORMAL, - `opencode.local:${server.port}`, - ) + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") } // Open localhost in browser diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..355b3ba0017 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -435,6 +435,10 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 66843aedc11..7b9a8c2076a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -308,6 +308,8 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { + await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + authProvider = new McpOAuthProvider( key, mcp.url, @@ -315,6 +317,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -344,6 +347,7 @@ export namespace MCP { let lastError: Error | undefined const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + for (const { name, transport } of transports) { try { const client = new Client({ @@ -570,7 +574,8 @@ export namespace MCP { for (const [clientName, client] of Object.entries(clientsSnapshot)) { // Only include tools from connected MCPs (skip disabled ones) - if (s.status[clientName]?.status !== "connected") { + const clientStatus = s.status[clientName]?.status + if (clientStatus !== "connected") { continue } @@ -720,8 +725,10 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) } - // Start the callback server - await McpOAuthCallback.ensureRunning() + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) // Generate and store a cryptographically secure state parameter BEFORE creating the provider // The SDK will call provider.state() to read this value @@ -731,8 +738,6 @@ export namespace MCP { await McpAuth.updateOAuthState(mcpName, oauthState) // Create a new auth provider for this flow - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -741,6 +746,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -769,6 +775,7 @@ export namespace MCP { pendingOAuthTransports.set(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } + throw error } } @@ -778,9 +785,9 @@ export namespace MCP { * Opens the browser and waits for callback. */ export async function authenticate(mcpName: string): Promise { - const { authorizationUrl } = await startAuth(mcpName) + const result = await startAuth(mcpName) - if (!authorizationUrl) { + if (!result.authorizationUrl) { // Already authenticated const s = await state() return s.status[mcpName] ?? { status: "connected" } @@ -794,9 +801,9 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser - log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) + log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState }) try { - const subprocess = await open(authorizationUrl) + const subprocess = await open(result.authorizationUrl) // The open package spawns a detached process and returns immediately. // We need to listen for errors which fire asynchronously: // - "error" event: command not found (ENOENT) @@ -819,7 +826,7 @@ export namespace MCP { // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) // Emit event so CLI can display the URL for manual opening log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) + Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }) } // Wait for callback using the OAuth state parameter diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index bb3b56f2e95..a690ab5e336 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,8 +1,12 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + const HTML_SUCCESS = ` @@ -56,21 +60,33 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - export async function ensureRunning(): Promise { + export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + if (server) return - const running = await isPortInUse() + const running = await isPortInUse(port) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { port }) return } + currentPort = port + currentPath = path + server = Bun.serve({ - port: OAUTH_CALLBACK_PORT, + port: currentPort, fetch(req) { const url = new URL(req.url) - if (url.pathname !== OAUTH_CALLBACK_PATH) { + if (url.pathname !== currentPath) { return new Response("Not found", { status: 404 }) } @@ -133,7 +149,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server started", { port: currentPort, path: currentPath }) } export function waitForCallback(oauthState: string): Promise { @@ -158,11 +174,11 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { return new Promise((resolve) => { Bun.connect({ hostname: "127.0.0.1", - port: OAUTH_CALLBACK_PORT, + port, socket: { open(socket) { socket.end() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 35ead25e8be..82bad60da33 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string + redirectUri?: string } export interface McpOAuthCallbacks { @@ -32,6 +33,10 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { + // Use configured redirectUri if provided, otherwise use OpenCode defaults + if (this.config.redirectUri) { + return this.config.redirectUri + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -152,3 +157,22 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d..2351fdafb2e 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,7 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { KiroAuthPlugin } from "./kiro" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -18,7 +19,7 @@ export namespace Plugin { const BUILTIN = ["opencode-anthropic-auth@0.0.9", "@gitlab/opencode-gitlab-auth@1.3.0"] // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, KiroAuthPlugin] const state = Instance.state(async () => { const client = createOpencodeClient({ diff --git a/packages/opencode/src/plugin/kiro.ts b/packages/opencode/src/plugin/kiro.ts new file mode 100644 index 00000000000..a0bab253f39 --- /dev/null +++ b/packages/opencode/src/plugin/kiro.ts @@ -0,0 +1,183 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import * as path from "path" +import * as os from "os" + +interface KiroToken { + access_token: string + expires_at: string + refresh_token: string + region: string + start_url: string + oauth_flow: string + scopes: string[] +} + +function getKiroDbPath(): string { + switch (process.platform) { + case "darwin": + return path.join(os.homedir(), "Library/Application Support/kiro-cli/data.sqlite3") + case "win32": + return path.join(process.env.APPDATA || "", "kiro-cli/data.sqlite3") + default: + return path.join(os.homedir(), ".local/share/kiro-cli/data.sqlite3") + } +} + +async function getKiroToken(): Promise { + const dbPath = getKiroDbPath() + const file = Bun.file(dbPath) + if (!(await file.exists())) return null + + try { + const { Database } = await import("bun:sqlite") + const db = new Database(dbPath, { readonly: true }) + const row = db + .query<{ value: string }, [string]>("SELECT value FROM auth_kv WHERE key = ?") + .get("kirocli:odic:token") + db.close() + + if (!row) return null + return JSON.parse(row.value) as KiroToken + } catch { + return null + } +} + +async function isTokenValid(token: KiroToken): Promise { + try { + const expiresAt = new Date(token.expires_at).getTime() + // Add 5 minute buffer + return expiresAt > Date.now() + 5 * 60 * 1000 + } catch { + return false + } +} + +export async function KiroAuthPlugin(_input: PluginInput): Promise { + return { + auth: { + provider: "kiro", + async loader(getAuth, provider) { + const info = await getAuth() + if (!info || info.type !== "oauth") return {} + + // Get token to determine region for baseURL + const token = await getKiroToken() + if (!token) return {} + + const region = token.region || "us-east-1" + const baseURL = `https://codewhisperer.${region}.amazonaws.com` + + // Set cost to 0 for subscription models + if (provider?.models) { + for (const model of Object.values(provider.models)) { + model.cost = { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + } + } + } + + return { + baseURL, + async fetch(request: RequestInfo | URL, init?: RequestInit) { + // Re-fetch token to get latest access token + const currentToken = await getKiroToken() + if (!currentToken) { + throw new Error("Kiro CLI token not found. Please run 'kiro login' first.") + } + + if (!(await isTokenValid(currentToken))) { + throw new Error("Kiro CLI token expired. Please run 'kiro login' to refresh.") + } + + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${currentToken.access_token}`) + headers.set("x-amzn-codewhisperer-optout", "false") + + // Remove any existing API key headers + headers.delete("x-api-key") + + return fetch(request, { + ...init, + headers, + }) + }, + } + }, + methods: [ + { + type: "oauth", + label: "Use existing Kiro CLI login", + async authorize() { + const token = await getKiroToken() + if (!token) { + return { + url: "https://kiro.dev/docs/cli/installation/", + instructions: + "Kiro CLI is not installed or not logged in. Please install Kiro CLI and run 'kiro login' first, then press Enter to retry.", + method: "auto" as const, + async callback() { + // Re-check token after user completes installation + const newToken = await getKiroToken() + if (!newToken || !(await isTokenValid(newToken))) { + return { type: "failed" as const } + } + const expiresAt = new Date(newToken.expires_at).getTime() + return { + type: "success" as const, + refresh: newToken.refresh_token, + access: newToken.access_token, + expires: expiresAt, + } + }, + } + } + + if (!(await isTokenValid(token))) { + return { + url: "", + instructions: + "Kiro CLI token has expired. Please run 'kiro login' to refresh your credentials, then press Enter to retry.", + method: "auto" as const, + async callback() { + const newToken = await getKiroToken() + if (!newToken || !(await isTokenValid(newToken))) { + return { type: "failed" as const } + } + const expiresAt = new Date(newToken.expires_at).getTime() + return { + type: "success" as const, + refresh: newToken.refresh_token, + access: newToken.access_token, + expires: expiresAt, + } + }, + } + } + + // Token exists and is valid + const expiresAt = new Date(token.expires_at).getTime() + return { + url: "", + instructions: "Using existing Kiro CLI credentials", + method: "auto" as const, + async callback() { + return { + type: "success" as const, + refresh: token.refresh_token, + access: token.access_token, + expires: expiresAt, + } + }, + } + }, + }, + ], + }, + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index bcb115edf41..e2746277332 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -25,6 +25,7 @@ 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 { createKiro } from "./sdk/kiro/src" import { createXai } from "@ai-sdk/xai" import { createMistral } from "@ai-sdk/mistral" import { createGroq } from "@ai-sdk/groq" @@ -37,6 +38,19 @@ import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { createGitLab } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" +import * as path from "path" +import * as os from "os" + +function getKiroDbPath(): string { + switch (process.platform) { + case "darwin": + return path.join(os.homedir(), "Library/Application Support/kiro-cli/data.sqlite3") + case "win32": + return path.join(process.env.APPDATA || "", "kiro-cli/data.sqlite3") + default: + return path.join(os.homedir(), ".local/share/kiro-cli/data.sqlite3") + } +} export namespace Provider { const log = Log.create({ service: "provider" }) @@ -64,6 +78,8 @@ export namespace Provider { "@gitlab/gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, + // @ts-ignore + "@ai-sdk/kiro": createKiro, } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise @@ -498,6 +514,27 @@ export namespace Provider { }, } }, + kiro: async (input) => { + // Check if Kiro CLI authentication exists + const dbPath = getKiroDbPath() + const hasAuth = await Bun.file(dbPath).exists() + + if (!hasAuth) { + // No auth, hide all models + for (const key of Object.keys(input.models)) { + delete input.models[key] + } + } + + return { + autoload: hasAuth, + options: { + headers: { + "x-kiro-client": "opencode", + }, + }, + } + }, } export const Model = z @@ -704,6 +741,180 @@ export namespace Provider { } } + // Add Kiro provider with Claude models + const kiroModels: Record = { + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + providerID: "kiro", + name: "Claude Sonnet 4.5", + family: "claude-sonnet", + api: { + id: "claude-sonnet-4-5", + url: "https://codewhisperer.us-east-1.amazonaws.com", + npm: "@ai-sdk/kiro", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 64000 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + release_date: "2025-09-29", + variants: { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + }, + }, + "claude-opus-4-5": { + id: "claude-opus-4-5", + providerID: "kiro", + name: "Claude Opus 4.5", + family: "claude-opus", + api: { + id: "claude-opus-4-5", + url: "https://codewhisperer.us-east-1.amazonaws.com", + npm: "@ai-sdk/kiro", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 32000 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + release_date: "2025-11-01", + variants: { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + }, + }, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + providerID: "kiro", + name: "Claude Haiku 4.5", + family: "claude-haiku", + api: { + id: "claude-haiku-4-5", + url: "https://codewhisperer.us-east-1.amazonaws.com", + npm: "@ai-sdk/kiro", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 8192 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2025-10-01", + variants: {}, + }, + "claude-sonnet-4": { + id: "claude-sonnet-4", + providerID: "kiro", + name: "Claude Sonnet 4", + family: "claude-sonnet", + api: { + id: "claude-sonnet-4", + url: "https://codewhisperer.us-east-1.amazonaws.com", + npm: "@ai-sdk/kiro", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 64000 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2025-05-14", + variants: {}, + }, + "claude-3-7-sonnet": { + id: "claude-3-7-sonnet", + providerID: "kiro", + name: "Claude 3.7 Sonnet", + family: "claude-sonnet", + api: { + id: "claude-3-7-sonnet", + url: "https://codewhisperer.us-east-1.amazonaws.com", + npm: "@ai-sdk/kiro", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, output: 64000 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { field: "reasoning_content" }, + }, + release_date: "2025-02-19", + variants: {}, + }, + } + + database["kiro"] = { + id: "kiro", + name: "Kiro (AWS)", + source: "custom", + env: [], + options: {}, + models: kiroModels, + } + function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -999,24 +1210,6 @@ export namespace Provider { opts.signal = combined } - // Strip openai itemId metadata following what codex does - // Codex uses #[serde(skip_serializing)] on id fields for all item types: - // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall - // IDs are only re-attached for Azure with store=true - if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { - const body = JSON.parse(opts.body as string) - const isAzure = model.providerID.includes("azure") - const keepIds = isAzure && body.store === true - if (!keepIds && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) { - delete item.id - } - } - opts.body = JSON.stringify(body) - } - } - return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/sdk/kiro/src/converters.ts b/packages/opencode/src/provider/sdk/kiro/src/converters.ts new file mode 100644 index 00000000000..a3afd51606a --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/converters.ts @@ -0,0 +1,514 @@ +import type { + LanguageModelV2FunctionTool, + LanguageModelV2Prompt, + LanguageModelV2ToolCallPart, + LanguageModelV2ToolResultPart, +} from "@ai-sdk/provider" + +export interface KiroTool { + toolSpecification: { + name: string + description: string + inputSchema: { json: object } + } +} + +export interface KiroToolResult { + content: Array<{ text: string }> + status: "success" | "error" + toolUseId: string +} + +export interface KiroHistoryItem { + userInputMessage?: { + content: string + modelId: string + origin: string + userInputMessageContext?: { + tools?: KiroTool[] + toolResults?: KiroToolResult[] + } + } + assistantResponseMessage?: { + content: string + messageId?: string + modelId?: string + toolUses?: Array<{ + name: string + toolUseId: string + input: unknown + }> + reasoning?: { + thinking?: string + } + } +} + +export interface KiroPayload { + conversationState: { + chatTriggerType: "MANUAL" + conversationId: string + currentMessage: { + userInputMessage: { + content: string + modelId: string + origin: string + userInputMessageContext?: { + tools?: KiroTool[] + toolResults?: KiroToolResult[] + systemPrompt?: string + } + } + } + history: KiroHistoryItem[] + } + profileArn?: string +} + +function extractTextContent( + content: + | string + | Array< + | { type: "text"; text: string } + | { type: "image"; image: unknown; mimeType?: string } + | { type: "file"; data: unknown; mimeType?: string } + >, +): string { + if (typeof content === "string") return content + return content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") +} + +/** + * Sanitizes JSON Schema from fields that Kiro API doesn't accept. + * + * Kiro API returns 400 "Improperly formed request" error if: + * - required is an empty array [] + * - additionalProperties is present in schema + */ +function sanitizeJsonSchema(schema: Record | undefined): Record { + if (!schema) return {} + + const result: Record = {} + + for (const [key, value] of Object.entries(schema)) { + // Skip empty required arrays + if (key === "required" && Array.isArray(value) && value.length === 0) { + continue + } + + // Skip additionalProperties - Kiro API doesn't support it + if (key === "additionalProperties") { + continue + } + + // Recursively process nested objects + if (key === "properties" && typeof value === "object" && value !== null) { + const properties: Record = {} + for (const [propName, propValue] of Object.entries(value as Record)) { + properties[propName] = + typeof propValue === "object" && propValue !== null + ? sanitizeJsonSchema(propValue as Record) + : propValue + } + result[key] = properties + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = sanitizeJsonSchema(value as Record) + } else if (Array.isArray(value)) { + // Process arrays (e.g., anyOf, oneOf) + result[key] = value.map((item) => + typeof item === "object" && item !== null ? sanitizeJsonSchema(item as Record) : item, + ) + } else { + result[key] = value + } + } + + return result +} + +function convertTools(tools?: LanguageModelV2FunctionTool[]): KiroTool[] | undefined { + if (!tools || tools.length === 0) return undefined + + return tools.map((tool) => ({ + toolSpecification: { + name: tool.name, + description: tool.description || `Tool: ${tool.name}`, + inputSchema: { json: sanitizeJsonSchema(tool.inputSchema as Record) }, + }, + })) +} + +function convertToolResults(parts: LanguageModelV2ToolResultPart[]): KiroToolResult[] { + return parts.map((part) => { + let outputText: string + + // Handle LanguageModelV2ToolResultOutput format + const output = part.output as unknown + if (output && typeof output === "object" && "type" in output && "value" in output) { + // Standard LanguageModelV2ToolResultOutput format: { type: 'text'|'json'|'error-text', value: ... } + const typed = output as { type: string; value: unknown } + if (typed.type === "text" || typed.type === "error-text") { + outputText = String(typed.value) + } else if (typed.type === "json") { + outputText = JSON.stringify(typed.value) + } else { + outputText = JSON.stringify(typed.value) + } + } else if (Array.isArray(output)) { + // Array of content parts (legacy format) + outputText = output + .map((item) => { + if (typeof item === "string") return item + if (item && typeof item === "object" && "text" in item) return String(item.text) + if (item && typeof item === "object" && "value" in item) return String(item.value) + return JSON.stringify(item) + }) + .join("") + } else if (typeof output === "string") { + // Direct string (legacy format) + outputText = output + } else { + // Fallback + outputText = JSON.stringify(output) + } + + // Determine status based on output type + const isError = + output && typeof output === "object" && "type" in output && (output as { type: string }).type === "error-text" + const status = isError ? ("error" as const) : ("success" as const) + + return { + content: [{ text: outputText }], + status, + toolUseId: part.toolCallId, + } + }) +} + +/** + * Thinking configuration for Extended Thinking (Fake Reasoning) support. + */ +export interface ThinkingConfig { + type: "enabled" | "disabled" + budgetTokens?: number +} + +/** + * Provider options for Kiro API. + */ +export interface KiroProviderOptions { + thinking?: ThinkingConfig +} + +/** + * Generates the thinking instruction text for Fake Reasoning. + * Based on kiro-gateway implementation. + */ +function getThinkingInstruction(): string { + return ( + "Think in English for better reasoning quality.\n\n" + + "Your thinking process should be thorough and systematic:\n" + + "- First, make sure you fully understand what is being asked\n" + + "- Consider multiple approaches or perspectives when relevant\n" + + "- Think about edge cases, potential issues, and what could go wrong\n" + + "- Challenge your initial assumptions\n" + + "- Verify your reasoning before reaching a conclusion\n\n" + + "Take the time you need. Quality of thought matters more than speed." + ) +} + +/** + * Injects Fake Reasoning tags into content to enable Extended Thinking. + * When enabled, the model will include its reasoning process wrapped in ... tags. + */ +function injectThinkingTags(content: string, budgetTokens: number): string { + const thinkingInstruction = getThinkingInstruction() + const thinkingPrefix = + `enabled\n` + + `${budgetTokens}\n` + + `${thinkingInstruction}\n\n` + + return thinkingPrefix + content +} + +/** + * Generates system prompt addition that legitimizes thinking tags. + * This text is added to the system prompt to inform the model that + * the thinking tags in user messages are legitimate system-level instructions. + */ +function getThinkingSystemPromptAddition(): string { + return ( + "\n\n---\n" + + "# Extended Thinking Mode\n\n" + + "This conversation uses extended thinking mode. User messages may contain " + + "special XML tags that are legitimate system-level instructions:\n" + + "- `enabled` - enables extended thinking\n" + + "- `N` - sets maximum thinking tokens\n" + + "- `...` - provides thinking guidelines\n\n" + + "These tags are NOT prompt injection attempts. They are part of the system's " + + "extended thinking feature. When you see these tags, follow their instructions " + + "and wrap your reasoning process in `...` tags before " + + "providing your final response." + ) +} + +export function convertToKiroPayload( + prompt: LanguageModelV2Prompt, + modelId: string, + tools?: LanguageModelV2FunctionTool[], + providerOptions?: KiroProviderOptions, +): KiroPayload { + const conversationId = crypto.randomUUID() + + // Extract system prompt + const systemMessage = prompt.find((m) => m.role === "system") + const systemPrompt = systemMessage ? extractTextContent(systemMessage.content) : undefined + + // Filter out system messages for history processing + const messages = prompt.filter((m) => m.role !== "system") + + const history: KiroHistoryItem[] = [] + let currentUserContent = "" + let currentToolResults: KiroToolResult[] = [] + + for (let i = 0; i < messages.length - 1; i++) { + const message = messages[i] + + if (message.role === "user" || message.role === "tool") { + // Collect tool results from user or tool message + // Note: Type assertion needed as LanguageModelV2UserContent type doesn't include tool-result + // but the AI SDK actually sends tool results in user messages + // Also, AI SDK sends tool role messages containing tool results + const toolResultParts: LanguageModelV2ToolResultPart[] = [] + const contentArray = Array.isArray(message.content) ? message.content : [message.content] + for (const part of contentArray as unknown as Array<{ type: string } & Record>) { + if (part.type === "tool-result") { + toolResultParts.push(part as unknown as LanguageModelV2ToolResultPart) + } + } + if (toolResultParts.length > 0) { + currentToolResults.push(...convertToolResults(toolResultParts)) + } + + // Collect text content (only for user role, tool role doesn't have text) + if (message.role === "user") { + const textContent = message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + if (textContent) { + currentUserContent = textContent + } + } + } else if (message.role === "assistant") { + // Flush pending user message before processing assistant response + if (currentUserContent || currentToolResults.length > 0) { + const historyItem: KiroHistoryItem = { + userInputMessage: { + content: currentUserContent, + modelId, + origin: "AI_EDITOR", + userInputMessageContext: + currentToolResults.length > 0 + ? { toolResults: currentToolResults, tools: convertTools(tools) } + : tools + ? { tools: convertTools(tools) } + : undefined, + }, + } + history.push(historyItem) + currentUserContent = "" + currentToolResults = [] + } + + // Process assistant message + const textContent = message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + + const toolCalls: LanguageModelV2ToolCallPart[] = [] + for (const part of message.content) { + if (part.type === "tool-call") { + toolCalls.push(part as LanguageModelV2ToolCallPart) + } + } + + const reasoningParts = message.content.filter( + (part): part is { type: "reasoning"; text: string } => part.type === "reasoning", + ) + + const assistantItem: KiroHistoryItem = { + assistantResponseMessage: { + content: textContent || "(empty)", + messageId: crypto.randomUUID(), + modelId, + ...(toolCalls.length > 0 && { + toolUses: toolCalls.map((tc) => { + // input can be a JSON string or object - ensure it's an object + let inputObj: unknown + if (typeof tc.input === "string") { + try { + inputObj = JSON.parse(tc.input) + } catch { + inputObj = {} + } + } else { + inputObj = tc.input ?? {} + } + return { + name: tc.toolName, + toolUseId: tc.toolCallId, + input: inputObj, + } + }), + }), + ...(reasoningParts.length > 0 && { + reasoning: { + thinking: reasoningParts.map((r) => r.text).join("\n"), + }, + }), + }, + } + + // Check if the previous history item is also an assistant message + // Kiro API requires alternating user/assistant messages, so we merge consecutive assistants + const lastItem = history[history.length - 1] + if (lastItem?.assistantResponseMessage) { + // Merge with previous assistant message + const lastAssistant = lastItem.assistantResponseMessage + lastAssistant.content += "\n\n" + (textContent || "(empty)") + + // Merge toolUses if present + if (toolCalls.length > 0) { + if (!lastAssistant.toolUses) lastAssistant.toolUses = [] + lastAssistant.toolUses.push( + ...toolCalls.map((tc) => { + let inputObj: unknown + if (typeof tc.input === "string") { + try { + inputObj = JSON.parse(tc.input) + } catch { + inputObj = {} + } + } else { + inputObj = tc.input ?? {} + } + return { + name: tc.toolName, + toolUseId: tc.toolCallId, + input: inputObj, + } + }), + ) + } + + // Merge reasoning if present + if (reasoningParts.length > 0) { + if (!lastAssistant.reasoning) lastAssistant.reasoning = { thinking: "" } + lastAssistant.reasoning.thinking = + (lastAssistant.reasoning.thinking ? lastAssistant.reasoning.thinking + "\n" : "") + + reasoningParts.map((r) => r.text).join("\n") + } + } else { + // Normal case: add new history item + history.push(assistantItem) + } + } + } + + // Process the last message as current message + const lastMessage = messages[messages.length - 1] + let lastUserContent = "" + let lastToolResults: KiroToolResult[] = [] + + if (lastMessage?.role === "user" || lastMessage?.role === "tool") { + const toolResultParts: LanguageModelV2ToolResultPart[] = [] + const contentArray = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content] + for (const part of contentArray as unknown as Array<{ type: string } & Record>) { + if (part.type === "tool-result") { + toolResultParts.push(part as unknown as LanguageModelV2ToolResultPart) + } + } + if (toolResultParts.length > 0) { + lastToolResults = convertToolResults(toolResultParts) + } + + // Collect text content (only for user role, tool role doesn't have text) + if (lastMessage.role === "user") { + const textContent = lastMessage.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + lastUserContent = textContent + } + } + + // Build userInputMessageContext - only include if has content + const userInputMessageContext: { + systemPrompt?: string + tools?: KiroTool[] + toolResults?: KiroToolResult[] + } = {} + + // Check if thinking mode is enabled + const thinkingEnabled = providerOptions?.thinking?.type === "enabled" + const thinkingBudgetTokens = providerOptions?.thinking?.budgetTokens || 16000 + + // Add system prompt with thinking mode addition if enabled + if (systemPrompt) { + let finalSystemPrompt = systemPrompt + if (thinkingEnabled) { + finalSystemPrompt = systemPrompt + getThinkingSystemPromptAddition() + } + userInputMessageContext.systemPrompt = finalSystemPrompt + } else if (thinkingEnabled) { + // If no system prompt but thinking is enabled, add the thinking system prompt + userInputMessageContext.systemPrompt = getThinkingSystemPromptAddition().trim() + } + + const kiroTools = convertTools(tools) + if (kiroTools) { + userInputMessageContext.tools = kiroTools + } + + if (lastToolResults.length > 0) { + userInputMessageContext.toolResults = lastToolResults + } + + // Inject thinking tags into user content if thinking mode is enabled + let finalUserContent = lastUserContent || "." + if (thinkingEnabled && lastUserContent) { + finalUserContent = injectThinkingTags(lastUserContent, thinkingBudgetTokens) + } + + // Build userInputMessage + const userInputMessage: { + content: string + modelId: string + origin: string + userInputMessageContext?: typeof userInputMessageContext + } = { + content: finalUserContent, // Use minimal content to avoid triggering AI to "continue" + modelId, + origin: "AI_EDITOR", + } + + // Only add userInputMessageContext if it has content + if (Object.keys(userInputMessageContext).length > 0) { + userInputMessage.userInputMessageContext = userInputMessageContext + } + + // Build conversationState + const conversationState: KiroPayload["conversationState"] = { + chatTriggerType: "MANUAL", + conversationId, + currentMessage: { userInputMessage }, + history, + } + + return { conversationState } +} diff --git a/packages/opencode/src/provider/sdk/kiro/src/index.ts b/packages/opencode/src/provider/sdk/kiro/src/index.ts new file mode 100644 index 00000000000..a70d3fdee9b --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/index.ts @@ -0,0 +1,2 @@ +export { createKiro } from "./kiro-provider" +export type { KiroProvider, KiroProviderSettings } from "./kiro-provider" diff --git a/packages/opencode/src/provider/sdk/kiro/src/kiro-language-model.ts b/packages/opencode/src/provider/sdk/kiro/src/kiro-language-model.ts new file mode 100644 index 00000000000..54189f199fa --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/kiro-language-model.ts @@ -0,0 +1,431 @@ +import type { + LanguageModelV2, + LanguageModelV2CallWarning, + LanguageModelV2Content, + LanguageModelV2FinishReason, + LanguageModelV2FunctionTool, + LanguageModelV2StreamPart, + LanguageModelV2Usage, +} from "@ai-sdk/provider" +import type { FetchFunction } from "@ai-sdk/provider-utils" +import { convertToKiroPayload, type KiroProviderOptions } from "./converters" +import { normalizeModelName } from "./model-resolver" +import { parseAwsEventStream, type KiroEvent } from "./streaming" + +export interface KiroLanguageModelConfig { + provider: string + apiKey?: string + baseURL: string + headers?: Record + fetch?: FetchFunction +} + +function headersToRecord(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key] = value + }) + return result +} + +export class KiroLanguageModel implements LanguageModelV2 { + readonly specificationVersion = "v2" + readonly modelId: string + private readonly config: KiroLanguageModelConfig + + readonly supportedUrls: Record = { + "image/*": [/^https?:\/\/.*$/], + "application/pdf": [/^https?:\/\/.*$/], + } + + constructor(modelId: string, config: KiroLanguageModelConfig) { + this.modelId = modelId + this.config = config + } + + get provider(): string { + return this.config.provider + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const result = await this.doStream(options) + const reader = result.stream.getReader() + + const content: LanguageModelV2Content[] = [] + let finishReason: LanguageModelV2FinishReason = "unknown" + const usage: LanguageModelV2Usage = { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + } + const warnings: LanguageModelV2CallWarning[] = [] + + let currentText = "" + let currentTextId: string | null = null + const toolCalls: Map = new Map() + let currentReasoning = "" + let currentReasoningId: string | null = null + + while (true) { + const { done, value } = await reader.read() + if (done) break + + switch (value.type) { + case "stream-start": + warnings.push(...(value.warnings || [])) + break + + case "text-start": + currentTextId = value.id + currentText = "" + break + + case "text-delta": + currentText += value.delta + break + + case "text-end": + if (currentText) { + content.push({ + type: "text", + text: currentText, + }) + } + currentTextId = null + break + + case "reasoning-start": + currentReasoningId = value.id + currentReasoning = "" + break + + case "reasoning-delta": + currentReasoning += value.delta + break + + case "reasoning-end": + if (currentReasoning) { + content.push({ + type: "reasoning", + text: currentReasoning, + }) + } + currentReasoningId = null + break + + case "tool-input-start": + toolCalls.set(value.id, { toolName: value.toolName, input: "" }) + break + + case "tool-input-delta": + const toolCall = toolCalls.get(value.id) + if (toolCall) { + toolCall.input += value.delta + } + break + + case "tool-call": + content.push({ + type: "tool-call", + toolCallId: value.toolCallId, + toolName: value.toolName, + input: value.input, + }) + break + + case "finish": + finishReason = value.finishReason + if (value.usage) { + usage.inputTokens = value.usage.inputTokens + usage.outputTokens = value.usage.outputTokens + usage.totalTokens = value.usage.totalTokens + } + break + } + } + + // Handle any remaining text + if (currentTextId && currentText) { + content.push({ + type: "text", + text: currentText, + }) + } + + // Handle any remaining reasoning + if (currentReasoningId && currentReasoning) { + content.push({ + type: "reasoning", + text: currentReasoning, + }) + } + + return { + content, + finishReason, + usage, + warnings, + request: result.request, + response: result.response, + } + } + + async doStream( + options: Parameters[0], + ): Promise>> { + const kiroModelId = normalizeModelName(this.modelId) + const functionTools = options.tools?.filter((tool): tool is LanguageModelV2FunctionTool => tool.type === "function") + + // Extract Kiro-specific provider options for thinking mode + const kiroProviderOptions: KiroProviderOptions | undefined = options.providerOptions?.kiro as + | KiroProviderOptions + | undefined + + const payload = convertToKiroPayload(options.prompt, kiroModelId, functionTools, kiroProviderOptions) + + // 意味のあるコンテンツがない場合は早期リターン(無限ループ防止) + const currentMessage = payload.conversationState.currentMessage.userInputMessage + const hasUserContent = currentMessage.content && currentMessage.content !== "." + const hasToolResults = (currentMessage.userInputMessageContext?.toolResults?.length ?? 0) > 0 + + if (!hasUserContent && !hasToolResults) { + // 空のストリームを返して終了 + return { + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings: [] }) + controller.enqueue({ + type: "finish", + finishReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }) + controller.close() + }, + }), + request: { body: payload }, + response: { headers: {} }, + } + } + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/vnd.amazon.eventstream", + ...this.config.headers, + } + + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}` + } + + // Merge with request headers + const requestHeaders: Record = { ...headers } + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + if (value !== undefined) { + requestHeaders[key] = value + } + } + } + + const fetchFn = this.config.fetch ?? fetch + const url = `${this.config.baseURL}/generateAssistantResponse` + + // Debug log - also log original prompt structure + const promptSummary = options.prompt.map((m, i) => `[${i}] role=${m.role}`).join(", ") + const debugLog = `[${new Date().toISOString()}] URL: ${url}\nPrompt structure: ${promptSummary}\nPayload: ${JSON.stringify(payload, null, 2)}\n\n` + const fs = await import("fs/promises") + await fs.appendFile("/tmp/kiro-debug.log", debugLog) + + const response = await fetchFn(url, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify(payload), + signal: options.abortSignal, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Kiro API error: ${response.status} ${response.statusText} - ${errorText}`) + } + + if (!response.body) { + throw new Error("Response body is empty") + } + + const warnings: LanguageModelV2CallWarning[] = [] + + // Handle unsupported settings + if (options.topK != null) { + warnings.push({ type: "unsupported-setting", setting: "topK" }) + } + if (options.presencePenalty != null) { + warnings.push({ type: "unsupported-setting", setting: "presencePenalty" }) + } + if (options.frequencyPenalty != null) { + warnings.push({ type: "unsupported-setting", setting: "frequencyPenalty" }) + } + if (options.seed != null) { + warnings.push({ type: "unsupported-setting", setting: "seed" }) + } + if (options.stopSequences != null) { + warnings.push({ type: "unsupported-setting", setting: "stopSequences" }) + } + + const kiroStream = parseAwsEventStream(response.body) + + let finishReason: LanguageModelV2FinishReason = "unknown" + const usage: LanguageModelV2Usage = { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + } + let textId = crypto.randomUUID() + let reasoningId: string | null = null + let textStarted = false + let reasoningStarted = false + const toolCallIds: Map = new Map() // toolUseId -> toolName + + const responseHeaders = headersToRecord(response.headers) + + return { + stream: kiroStream.pipeThrough( + new TransformStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + }, + + transform(event, controller) { + switch (event.type) { + case "content": + if (!textStarted) { + textStarted = true + controller.enqueue({ + type: "text-start", + id: textId, + }) + } + controller.enqueue({ + type: "text-delta", + id: textId, + delta: event.content, + }) + break + + case "thinking_start": + reasoningId = crypto.randomUUID() + reasoningStarted = true + controller.enqueue({ + type: "reasoning-start", + id: reasoningId, + }) + break + + case "thinking": + if (reasoningId) { + controller.enqueue({ + type: "reasoning-delta", + id: reasoningId, + delta: event.thinking, + }) + } + break + + case "thinking_stop": + if (reasoningId) { + controller.enqueue({ + type: "reasoning-end", + id: reasoningId, + }) + reasoningId = null + reasoningStarted = false + } + break + + case "tool_start": + toolCallIds.set(event.toolUseId, event.name) + controller.enqueue({ + type: "tool-input-start", + id: event.toolUseId, + toolName: event.name, + }) + break + + case "tool_input": + controller.enqueue({ + type: "tool-input-delta", + id: event.toolUseId, + delta: event.input, + }) + break + + case "tool_stop": + controller.enqueue({ + type: "tool-input-end", + id: event.toolUseId, + }) + const toolName = toolCallIds.get(event.toolUseId) + if (toolName) { + controller.enqueue({ + type: "tool-call", + toolCallId: event.toolUseId, + toolName, + input: typeof event.input === "string" ? event.input : JSON.stringify(event.input), + }) + finishReason = "tool-calls" + } + break + + case "usage": + usage.inputTokens = event.inputTokens + usage.outputTokens = event.outputTokens + usage.totalTokens = event.inputTokens + event.outputTokens + break + + case "done": + if (finishReason === "unknown") { + finishReason = "stop" + } + break + + case "error": + controller.enqueue({ + type: "error", + error: new Error(event.error), + }) + finishReason = "error" + break + } + }, + + flush(controller) { + // Close any open text part + if (textStarted) { + controller.enqueue({ + type: "text-end", + id: textId, + }) + } + + // Close any open reasoning part + if (reasoningStarted && reasoningId) { + controller.enqueue({ + type: "reasoning-end", + id: reasoningId, + }) + } + + controller.enqueue({ + type: "finish", + finishReason, + usage, + }) + }, + }), + ), + request: { body: payload }, + response: { headers: responseHeaders }, + } + } +} diff --git a/packages/opencode/src/provider/sdk/kiro/src/kiro-provider.ts b/packages/opencode/src/provider/sdk/kiro/src/kiro-provider.ts new file mode 100644 index 00000000000..66af633422b --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/kiro-provider.ts @@ -0,0 +1,36 @@ +import type { LanguageModelV2 } from "@ai-sdk/provider" +import type { FetchFunction } from "@ai-sdk/provider-utils" +import { KiroLanguageModel } from "./kiro-language-model" + +export interface KiroProviderSettings { + apiKey?: string + baseURL?: string + region?: string + headers?: Record + fetch?: FetchFunction +} + +export interface KiroProvider { + (modelId: string): LanguageModelV2 + languageModel(modelId: string): LanguageModelV2 +} + +export function createKiro(options: KiroProviderSettings = {}): KiroProvider { + const region = options.region ?? "us-east-1" + const baseURL = options.baseURL ?? `https://codewhisperer.${region}.amazonaws.com` + + const createLanguageModel = (modelId: string): LanguageModelV2 => { + return new KiroLanguageModel(modelId, { + provider: "kiro", + apiKey: options.apiKey, + baseURL, + headers: options.headers, + fetch: options.fetch, + }) + } + + const provider = (modelId: string): LanguageModelV2 => createLanguageModel(modelId) + provider.languageModel = createLanguageModel + + return provider as KiroProvider +} diff --git a/packages/opencode/src/provider/sdk/kiro/src/model-resolver.ts b/packages/opencode/src/provider/sdk/kiro/src/model-resolver.ts new file mode 100644 index 00000000000..8f3573d00ee --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/model-resolver.ts @@ -0,0 +1,15 @@ +const HIDDEN_MODELS: Record = { + "claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0", + "claude-3-7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0", +} + +export function normalizeModelName(name: string): string { + // Convert model names like claude-sonnet-4-5 → claude-sonnet-4.5 + // or claude-haiku-4-5-20251001 → claude-haiku-4.5 + const normalized = name + .toLowerCase() + .replace(/-(\d+)-(\d{1,2})(?:-(?:\d{8}|latest))?$/, "-$1.$2") // 4-5 → 4.5 + .replace(/-(\d+)(?:-\d{8})?$/, "-$1") // 4-20250514 → 4 + + return HIDDEN_MODELS[normalized] ?? normalized +} diff --git a/packages/opencode/src/provider/sdk/kiro/src/streaming.ts b/packages/opencode/src/provider/sdk/kiro/src/streaming.ts new file mode 100644 index 00000000000..4ceeef4fba4 --- /dev/null +++ b/packages/opencode/src/provider/sdk/kiro/src/streaming.ts @@ -0,0 +1,614 @@ +export type KiroEventType = + | "content" + | "tool_start" + | "tool_input" + | "tool_stop" + | "thinking_start" + | "thinking" + | "thinking_stop" + | "usage" + | "done" + | "error" + +export interface KiroContentEvent { + type: "content" + content: string +} + +export interface KiroToolStartEvent { + type: "tool_start" + name: string + toolUseId: string +} + +export interface KiroToolInputEvent { + type: "tool_input" + toolUseId: string + input: string +} + +export interface KiroToolStopEvent { + type: "tool_stop" + toolUseId: string + input: unknown +} + +export interface KiroThinkingStartEvent { + type: "thinking_start" +} + +export interface KiroThinkingEvent { + type: "thinking" + thinking: string +} + +export interface KiroThinkingStopEvent { + type: "thinking_stop" +} + +export interface KiroUsageEvent { + type: "usage" + inputTokens: number + outputTokens: number +} + +export interface KiroDoneEvent { + type: "done" +} + +export interface KiroErrorEvent { + type: "error" + error: string +} + +export type KiroEvent = + | KiroContentEvent + | KiroToolStartEvent + | KiroToolInputEvent + | KiroToolStopEvent + | KiroThinkingStartEvent + | KiroThinkingEvent + | KiroThinkingStopEvent + | KiroUsageEvent + | KiroDoneEvent + | KiroErrorEvent + +// AWS Event Stream message header types +interface AwsEventStreamHeader { + name: string + type: number + value: string | number | ArrayBuffer +} + +function readUint32(view: DataView, offset: number): number { + return view.getUint32(offset, false) // big-endian +} + +function decodeAwsEventStreamMessage(buffer: ArrayBuffer): { + headers: Record + payload: Uint8Array +} | null { + if (buffer.byteLength < 16) return null + + const view = new DataView(buffer) + + // Read prelude + const totalLength = readUint32(view, 0) + const headersLength = readUint32(view, 4) + // const preludeCrc = readUint32(view, 8) + + if (buffer.byteLength < totalLength) return null + + // Read headers + const headers: Record = {} + let offset = 12 + const headersEnd = 12 + headersLength + + while (offset < headersEnd) { + const nameLength = view.getUint8(offset) + offset += 1 + const name = new TextDecoder().decode(new Uint8Array(buffer, offset, nameLength)) + offset += nameLength + const headerType = view.getUint8(offset) + offset += 1 + + let value: string | number | ArrayBuffer + switch (headerType) { + case 0: // bool true + value = 1 + break + case 1: // bool false + value = 0 + break + case 2: // byte + value = view.getInt8(offset) + offset += 1 + break + case 3: // short + value = view.getInt16(offset, false) + offset += 2 + break + case 4: // int + value = view.getInt32(offset, false) + offset += 4 + break + case 5: // long + // JavaScript doesn't handle 64-bit ints well, read as two 32-bit values + const high = view.getInt32(offset, false) + const low = view.getUint32(offset + 4, false) + value = high * 0x100000000 + low + offset += 8 + break + case 6: // bytes + const bytesLength = view.getUint16(offset, false) + offset += 2 + value = buffer.slice(offset, offset + bytesLength) + offset += bytesLength + break + case 7: // string + const stringLength = view.getUint16(offset, false) + offset += 2 + value = new TextDecoder().decode(new Uint8Array(buffer, offset, stringLength)) + offset += stringLength + break + case 8: // timestamp + const timestampHigh = view.getInt32(offset, false) + const timestampLow = view.getUint32(offset + 4, false) + value = timestampHigh * 0x100000000 + timestampLow + offset += 8 + break + case 9: // uuid + value = buffer.slice(offset, offset + 16) + offset += 16 + break + default: + throw new Error(`Unknown header type: ${headerType}`) + } + + headers[name] = value + } + + // Read payload + const payloadLength = totalLength - headersLength - 16 // 12 bytes prelude + 4 bytes message CRC + const payload = new Uint8Array(buffer, headersEnd, payloadLength) + + return { headers, payload } +} + +// Simple format: {"content": "..."} or {"name": "...", "toolUseId": "...", "input": "..."} etc. +interface KiroSimpleEvent { + content?: string + name?: string + toolUseId?: string + input?: string | Record + stop?: boolean + usage?: number + thinking?: string + stopReason?: string +} + +// Nested format: {"assistantResponseEvent": {...}} +interface KiroNestedEvent { + assistantResponseEvent?: { + contentBlockDeltaEvent?: { + delta?: { + reasoningContentBlockDelta?: { + thinking?: string + } + text?: string + toolUse?: { + input: string + } + } + } + contentBlockStartEvent?: { + start?: { + reasoningContent?: unknown + text?: string + toolUse?: { + name: string + toolUseId: string + } + } + } + contentBlockStopEvent?: { + contentBlockIndex: number + } + messageStartEvent?: unknown + messageStopEvent?: { + stopReason?: string + } + usageMetricsEvent?: { + inputTokens?: number + outputTokens?: number + latencyMs?: number + } + } + supplementaryWebLinksEvent?: unknown +} + +type KiroRawEvent = KiroSimpleEvent | KiroNestedEvent + +export function parseAwsEventStream(stream: ReadableStream): ReadableStream { + let buffer = new Uint8Array(0) + let currentToolCall: { toolUseId: string; input: string } | null = null + let inThinking = false + // For Fake Reasoning: track if we're inside tags in content + let inFakeThinking = false + let contentBuffer = "" + + // Helper to process content with tags (Fake Reasoning) + const processContentWithThinkingTags = ( + content: string, + controller: TransformStreamDefaultController, + ) => { + contentBuffer += content + + while (true) { + if (!inFakeThinking) { + // Look for tag + const thinkingStart = contentBuffer.indexOf("") + if (thinkingStart === -1) { + // No thinking tag found, output all content except last 10 chars (in case tag is split) + if (contentBuffer.length > 10) { + const safeContent = contentBuffer.slice(0, -10) + contentBuffer = contentBuffer.slice(-10) + if (safeContent) { + controller.enqueue({ type: "content", content: safeContent }) + } + } + break + } + + // Output content before + if (thinkingStart > 0) { + controller.enqueue({ type: "content", content: contentBuffer.slice(0, thinkingStart) }) + } + + // Enter thinking mode + inFakeThinking = true + controller.enqueue({ type: "thinking_start" }) + contentBuffer = contentBuffer.slice(thinkingStart + "".length) + } else { + // Look for tag + const thinkingEnd = contentBuffer.indexOf("") + if (thinkingEnd === -1) { + // No end tag found, output thinking content except last 11 chars + if (contentBuffer.length > 11) { + const safeThinking = contentBuffer.slice(0, -11) + contentBuffer = contentBuffer.slice(-11) + if (safeThinking) { + controller.enqueue({ type: "thinking", thinking: safeThinking }) + } + } + break + } + + // Output thinking content before + if (thinkingEnd > 0) { + controller.enqueue({ type: "thinking", thinking: contentBuffer.slice(0, thinkingEnd) }) + } + + // Exit thinking mode + inFakeThinking = false + controller.enqueue({ type: "thinking_stop" }) + contentBuffer = contentBuffer.slice(thinkingEnd + "".length) + } + } + } + + // Flush remaining content buffer + const flushContentBuffer = (controller: TransformStreamDefaultController) => { + if (contentBuffer.length > 0) { + if (inFakeThinking) { + controller.enqueue({ type: "thinking", thinking: contentBuffer }) + controller.enqueue({ type: "thinking_stop" }) + inFakeThinking = false + } else { + controller.enqueue({ type: "content", content: contentBuffer }) + } + contentBuffer = "" + } + } + + return stream.pipeThrough( + new TransformStream({ + async transform(chunk, controller) { + // Append chunk to buffer + const newBuffer = new Uint8Array(buffer.length + chunk.length) + newBuffer.set(buffer) + newBuffer.set(chunk, buffer.length) + buffer = newBuffer + + // Try to parse complete messages + while (buffer.length >= 12) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const totalLength = readUint32(view, 0) + + if (buffer.length < totalLength) break + + const messageBuffer = buffer.slice(0, totalLength).buffer + buffer = buffer.slice(totalLength) + + const message = decodeAwsEventStreamMessage(messageBuffer) + if (!message) { + continue + } + + // Check for exception + const exceptionType = message.headers[":exception-type"] + if (exceptionType) { + const payload = new TextDecoder().decode(message.payload) + try { + const errorJson = JSON.parse(payload) + controller.enqueue({ + type: "error", + error: errorJson.message || errorJson.Message || payload, + }) + } catch { + controller.enqueue({ + type: "error", + error: payload, + }) + } + continue + } + + // Parse JSON payload + if (message.payload.length === 0) continue + + try { + const payloadText = new TextDecoder().decode(message.payload) + const data = JSON.parse(payloadText) as KiroRawEvent + + // Handle simple format: {"content": "..."}, {"name": "...", "toolUseId": "..."}, etc. + const simple = data as KiroSimpleEvent + if (simple.content !== undefined) { + // Process content with Fake Reasoning tags + processContentWithThinkingTags(simple.content, controller) + continue + } + + if (simple.thinking !== undefined) { + if (!inThinking) { + inThinking = true + controller.enqueue({ type: "thinking_start" }) + } + controller.enqueue({ + type: "thinking", + thinking: simple.thinking, + }) + continue + } + + if (simple.name !== undefined && simple.toolUseId !== undefined) { + // Check if this is a new tool call or continuation of existing one + const isNewToolCall = + !currentToolCall || currentToolCall.toolUseId !== simple.toolUseId + + if (isNewToolCall && simple.input === undefined && simple.stop !== true) { + // New tool call start (no input yet) + currentToolCall = { + toolUseId: simple.toolUseId, + input: "", + } + controller.enqueue({ + type: "tool_start", + name: simple.name, + toolUseId: simple.toolUseId, + }) + continue + } + + // Ensure currentToolCall exists for input/stop processing + if (!currentToolCall || currentToolCall.toolUseId !== simple.toolUseId) { + currentToolCall = { + toolUseId: simple.toolUseId, + input: "", + } + controller.enqueue({ + type: "tool_start", + name: simple.name, + toolUseId: simple.toolUseId, + }) + } + + // Handle input if present + if (simple.input !== undefined) { + const inputDelta = + typeof simple.input === "object" + ? JSON.stringify(simple.input) + : String(simple.input) + currentToolCall.input += inputDelta + controller.enqueue({ + type: "tool_input", + toolUseId: currentToolCall.toolUseId, + input: inputDelta, + }) + } + + // Handle stop if present + if (simple.stop === true) { + let parsedInput: unknown = currentToolCall.input + try { + parsedInput = JSON.parse(currentToolCall.input) + } catch { + // Keep as string + } + controller.enqueue({ + type: "tool_stop", + toolUseId: currentToolCall.toolUseId, + input: parsedInput, + }) + currentToolCall = null + } + continue + } + + if (simple.input !== undefined && currentToolCall) { + // Tool input delta (without name/toolUseId) - input can be string or object + const inputDelta = + typeof simple.input === "object" + ? JSON.stringify(simple.input) + : String(simple.input) + currentToolCall.input += inputDelta + controller.enqueue({ + type: "tool_input", + toolUseId: currentToolCall.toolUseId, + input: inputDelta, + }) + continue + } + + if (simple.stop === true && currentToolCall) { + // Tool stop (without name/toolUseId) + let parsedInput: unknown = currentToolCall.input + try { + parsedInput = JSON.parse(currentToolCall.input) + } catch { + // Keep as string + } + controller.enqueue({ + type: "tool_stop", + toolUseId: currentToolCall.toolUseId, + input: parsedInput, + }) + currentToolCall = null + continue + } + + if (simple.usage !== undefined) { + controller.enqueue({ + type: "usage", + inputTokens: 0, + outputTokens: simple.usage, + }) + continue + } + + if (simple.stopReason !== undefined) { + if (inThinking) { + inThinking = false + controller.enqueue({ type: "thinking_stop" }) + } + controller.enqueue({ type: "done" }) + continue + } + + // Handle nested format: {"assistantResponseEvent": {...}} + const nested = data as KiroNestedEvent + if (nested.assistantResponseEvent) { + const event = nested.assistantResponseEvent + + // Content block start + if (event.contentBlockStartEvent?.start) { + const start = event.contentBlockStartEvent.start + + if (start.reasoningContent !== undefined) { + inThinking = true + controller.enqueue({ type: "thinking_start" }) + } else if (start.toolUse) { + currentToolCall = { + toolUseId: start.toolUse.toolUseId, + input: "", + } + controller.enqueue({ + type: "tool_start", + name: start.toolUse.name, + toolUseId: start.toolUse.toolUseId, + }) + } + } + + // Content block delta + if (event.contentBlockDeltaEvent?.delta) { + const delta = event.contentBlockDeltaEvent.delta + + if (delta.reasoningContentBlockDelta?.thinking) { + controller.enqueue({ + type: "thinking", + thinking: delta.reasoningContentBlockDelta.thinking, + }) + } else if (delta.text !== undefined) { + controller.enqueue({ + type: "content", + content: delta.text, + }) + } else if (delta.toolUse?.input !== undefined) { + if (currentToolCall) { + currentToolCall.input += delta.toolUse.input + controller.enqueue({ + type: "tool_input", + toolUseId: currentToolCall.toolUseId, + input: delta.toolUse.input, + }) + } + } + } + + // Content block stop + if (event.contentBlockStopEvent !== undefined) { + if (inThinking) { + inThinking = false + controller.enqueue({ type: "thinking_stop" }) + } else if (currentToolCall) { + let parsedInput: unknown = currentToolCall.input + try { + parsedInput = JSON.parse(currentToolCall.input) + } catch { + // Keep as string if not valid JSON + } + controller.enqueue({ + type: "tool_stop", + toolUseId: currentToolCall.toolUseId, + input: parsedInput, + }) + currentToolCall = null + } + } + + // Usage metrics + if (event.usageMetricsEvent) { + controller.enqueue({ + type: "usage", + inputTokens: event.usageMetricsEvent.inputTokens ?? 0, + outputTokens: event.usageMetricsEvent.outputTokens ?? 0, + }) + } + + // Message stop + if (event.messageStopEvent) { + controller.enqueue({ type: "done" }) + } + } + } catch (e) { + // Skip unparseable payloads + } + } + }, + + flush(controller) { + // Flush any remaining content buffer (Fake Reasoning) + flushContentBuffer(controller) + + // Handle any remaining incomplete state + if (inThinking) { + controller.enqueue({ type: "thinking_stop" }) + } + if (currentToolCall) { + let parsedInput: unknown = currentToolCall.input + try { + parsedInput = JSON.parse(currentToolCall.input) + } catch { + // Keep as string + } + controller.enqueue({ + type: "tool_stop", + toolUseId: currentToolCall.toolUseId, + input: parsedInput, + }) + } + }, + }), + ) +} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 79892db4cca..bfb7e254f11 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,33 +16,34 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { - // Maps npm package to the key the AI SDK expects for providerOptions - function sdkKey(npm: string): string | undefined { - switch (npm) { - case "@ai-sdk/github-copilot": - case "@ai-sdk/openai": - case "@ai-sdk/azure": - return "openai" - case "@ai-sdk/amazon-bedrock": - return "bedrock" - case "@ai-sdk/anthropic": - return "anthropic" - case "@ai-sdk/google-vertex": - case "@ai-sdk/google": - return "google" - case "@ai-sdk/gateway": - return "gateway" - case "@openrouter/ai-sdk-provider": - return "openrouter" - } - return undefined - } - function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, options: Record, ): ModelMessage[] { + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" || options.store === false) { + msgs = msgs.map((msg) => { + if (msg.providerOptions) { + for (const options of Object.values(msg.providerOptions)) { + delete options["itemId"] + } + } + if (!Array.isArray(msg.content)) { + return msg + } + const content = msg.content.map((part) => { + if (part.providerOptions) { + for (const options of Object.values(part.providerOptions)) { + delete options["itemId"] + } + } + return part + }) + return { ...msg, content } as typeof msg + }) + } + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -256,28 +257,6 @@ export namespace ProviderTransform { msgs = applyCaching(msgs, model.providerID) } - // Remap providerOptions keys from stored providerID to expected SDK key - const key = sdkKey(model.api.npm) - if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") { - const remap = (opts: Record | undefined) => { - if (!opts) return opts - if (!(model.providerID in opts)) return opts - const result = { ...opts } - result[key] = result[model.providerID] - delete result[model.providerID] - return result - } - - msgs = msgs.map((msg) => { - if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } - return { - ...msg, - providerOptions: remap(msg.providerOptions), - content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })), - } as typeof msg - }) - } - return msgs } @@ -497,6 +476,24 @@ export namespace ProviderTransform { case "@ai-sdk/perplexity": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity return {} + + case "@ai-sdk/kiro": + // Kiro uses "Fake Reasoning" - injecting thinking tags into prompts + // The model responds with ... blocks that are parsed as reasoning + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } } return {} } @@ -595,8 +592,39 @@ export namespace ProviderTransform { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - const key = sdkKey(model.api.npm) ?? model.providerID - return { [key]: options } + switch (model.api.npm) { + case "@ai-sdk/github-copilot": + case "@ai-sdk/openai": + case "@ai-sdk/azure": + return { + ["openai" as string]: options, + } + case "@ai-sdk/amazon-bedrock": + return { + ["bedrock" as string]: options, + } + case "@ai-sdk/anthropic": + return { + ["anthropic" as string]: options, + } + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return { + ["google" as string]: options, + } + case "@ai-sdk/gateway": + return { + ["gateway" as string]: options, + } + case "@openrouter/ai-sdk-provider": + return { + ["openrouter" as string]: options, + } + default: + return { + [model.providerID]: options, + } + } } export function maxOutputTokens( diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 953269de444..8bddb910503 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,17 +7,15 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number) { + export function publish(port: number, name = "opencode") { if (currentPort === port) return if (bonjour) unpublish() try { - const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", - host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 28dec7f4043..f0c64b49f81 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!) + MDNS.publish(server.port!, `opencode-${server.port!}`) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 00000000000..aa23f4dfb5d --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,75 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with default config when no redirectUri provided", async () => { + await McpOAuthCallback.ensureRunning() + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("starts server with custom redirectUri", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("is idempotent when called with same redirectUri", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("restarts server when redirectUri changes", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1") + expect(McpOAuthCallback.isRunning()).toBe(true) + + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("isRunning returns false when not started", async () => { + expect(McpOAuthCallback.isRunning()).toBe(false) + }) + + test("isRunning returns false after stop", async () => { + await McpOAuthCallback.ensureRunning() + await McpOAuthCallback.stop() + expect(McpOAuthCallback.isRunning()).toBe(false) + }) +}) + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("defaults to port 80 for http without explicit port", () => { + const result = parseRedirectUri("http://127.0.0.1/callback") + expect(result.port).toBe(80) + expect(result.path).toBe("/callback") + }) + + test("defaults to port 443 for https without explicit port", () => { + const result = parseRedirectUri("https://127.0.0.1/callback") + expect(result.port).toBe(443) + expect(result.path).toBe("/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dcf16c65cbd..33047b5bcb4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("preserves itemId and reasoningEncryptedContent when store=false", () => { + test("strips itemId and reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves other openai options including itemId", () => { + test("preserves other openai options when stripping itemId", () => { const msgs = [ { role: "assistant", @@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("preserves metadata for openai package when store is true", () => { + test("strips metadata for openai package even when store is true", () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package preserves itemId regardless of store value + // openai package always strips itemId regardless of store value const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves metadata for non-openai packages when store is false", () => { + test("strips metadata for non-openai packages when store is false", () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false preserves metadata for non-openai packages + // store=false triggers stripping even for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves metadata using providerID key when store is false", () => { + test("strips metadata using providerID key when store is false", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("preserves itemId across all providerOptions keys", () => { + test("strips itemId across all providerOptions keys", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") - expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") - expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") - expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") + expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() }) test("does not strip metadata for non-openai packages when store is not false", () => { @@ -914,88 +914,6 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }) }) -describe("ProviderTransform.message - providerOptions key remapping", () => { - const createModel = (providerID: string, npm: string) => - ({ - id: `${providerID}/test-model`, - providerID, - api: { - id: "test-model", - url: "https://api.test.com", - npm, - }, - name: "Test Model", - capabilities: { - temperature: true, - reasoning: false, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: true }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, - limit: { context: 128000, output: 8192 }, - status: "active", - options: {}, - headers: {}, - }) as any - - test("azure keeps 'azure' key and does not remap to 'openai'", () => { - const model = createModel("azure", "@ai-sdk/azure") - const msgs = [ - { - role: "user", - content: "Hello", - providerOptions: { - azure: { someOption: "value" }, - }, - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) - - expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) - expect(result[0].providerOptions?.openai).toBeUndefined() - }) - - test("openai with github-copilot npm remaps providerID to 'openai'", () => { - const model = createModel("github-copilot", "@ai-sdk/github-copilot") - const msgs = [ - { - role: "user", - content: "Hello", - providerOptions: { - "github-copilot": { someOption: "value" }, - }, - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) - - expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) - expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() - }) - - test("bedrock remaps providerID to 'bedrock' key", () => { - const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") - const msgs = [ - { - role: "user", - content: "Hello", - providerOptions: { - "my-bedrock": { someOption: "value" }, - }, - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) - - expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) - expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() - }) -}) - describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e47c4f5f7f1..32321a7dfd8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1530,6 +1530,10 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string + /** + * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). + */ + redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0dc174c1b0a..904f3eeaefa 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9122,6 +9122,10 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" + }, + "redirectUri": { + "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + "type": "string" } }, "additionalProperties": false diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 631b3e33a29..5be7f95aeec 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -69,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) + const element = scrollRef()?.querySelector(`[data-key="${key}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -81,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) + const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) element?.scrollIntoView({ block: "center" }) }) diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 26f312bda75..5ddf3fba329 100644 --- a/packages/ui/src/components/logo.tsx +++ b/packages/ui/src/components/logo.tsx @@ -13,21 +13,6 @@ export const Mark = (props: { class?: string }) => { ) } -export const Splash = (props: { class?: string }) => { - return ( - - - - - ) -} - export const Logo = (props: { class?: string }) => { return ( _`**: -```ts title=".opencode/tools/math.ts" +```ts title=".opencode/tool/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tools/project.ts" {8} +```ts title=".opencode/tool/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tools/add.py" +```python title=".opencode/tool/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tools/python-add.ts" {10} +```ts title=".opencode/tool/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index ce3e3deb86c..44a73de69e9 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -47,17 +47,16 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Projects -| Name | Description | -| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | -| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | -| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | -| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | -| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | -| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | -| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode | +| Name | Description | +| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | --- diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index a31fe1e7be8..6e8b9de4d79 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,10 +180,8 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 - use_github_token: true prompt: | Review this pull request: - Check for code quality issues diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index 57c1c54a956..a31a8223b07 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` +- Global: `~/.config/opencode/mode/` +- Project: `.opencode/mode/` -```markdown title="~/.config/opencode/modes/review.md" +```markdown title="~/.config/opencode/mode/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: +Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: -```markdown title=".opencode/modes/debug.md" +```markdown title=".opencode/mode/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/modes/refactor.md" +```markdown title="~/.config/opencode/mode/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 4df3841e34a..b4f0691ced7 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agents/review.md" +```markdown title="~/.config/opencode/agent/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index 66a1b3cad95..bf26744f6c4 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugins/` - Project-level plugins -- `~/.config/opencode/plugins/` - Global plugins +- `.opencode/plugin/` - Project-level plugins +- `~/.config/opencode/plugin/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugins/`) -4. Project plugin directory (`.opencode/plugins/`) +3. Global plugin directory (`~/.config/opencode/plugin/`) +4. Project plugin directory (`.opencode/plugin/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugins/my-plugin.ts" +```ts title=".opencode/plugin/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugins/example.js" +```js title=".opencode/plugin/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugins/notification.js" +```js title=".opencode/plugin/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugins/env-protection.js" +```javascript title=".opencode/plugin/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugins/custom-tools.ts" +```ts title=".opencode/plugin/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugins/my-plugin.ts" +```ts title=".opencode/plugin/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugins/compaction.ts" +```ts title=".opencode/plugin/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugins/custom-compaction.ts" +```ts title=".opencode/plugin/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 6022d174a7d..e1d684de00a 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,33 +558,6 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- -### Firmware - -1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. - -2. Run the `/connect` command and search for **Firmware**. - - ```txt - /connect - ``` - -3. Enter your Firmware API key. - - ```txt - ┌ API key - │ - │ - └ enter - ``` - -4. Run the `/models` command to select a model. - - ```txt - /models - ``` - ---- - ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 553931eec49..54c2c9d06ef 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skills//SKILL.md` -- Global config: `~/.config/opencode/skills//SKILL.md` +- Project config: `.opencode/skill//SKILL.md` +- Global config: `~/.config/opencode/skill//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skills/git-release/SKILL.md` like this: +Create `.opencode/skill/git-release/SKILL.md` like this: ```markdown ---