diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9859174a2e35..2281e5a61295 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,19 +8,37 @@ inputs: runs: using: "composite" steps: - - name: Get baseline download URL + - name: Ensure build tools are available + if: runner.os == 'Linux' + shell: bash + # Blacksmith's ARM64 ubuntu-2404 runners ship a minimal image that is missing: + # unzip — needed by oven-sh/setup-bun to extract the downloaded bun zip + # build-essential (make, g++) — needed by node-gyp to compile native addons + # (e.g. tree-sitter-powershell) during `bun install` + run: | + PKGS=() + command -v unzip >/dev/null 2>&1 || PKGS+=(unzip) + command -v make >/dev/null 2>&1 || PKGS+=(build-essential) + [ ${#PKGS[@]} -gt 0 ] && sudo apt-get install -y --no-install-recommends "${PKGS[@]}" + true + + - name: Get bun download URL id: bun-url shell: bash run: | - if [ "$RUNNER_ARCH" = "X64" ]; then - V=$(node -p "require('./package.json').packageManager.split('@')[1]") - case "$RUNNER_OS" in - macOS) OS=darwin ;; - Linux) OS=linux ;; - Windows) OS=windows ;; - esac - echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT" - fi + V=$(node -p "require('./package.json').packageManager.split('@')[1]") + case "$RUNNER_OS" in + macOS) OS=darwin ;; + Linux) OS=linux ;; + Windows) OS=windows ;; + *) exit 0 ;; + esac + case "$RUNNER_ARCH" in + X64) ARCH=x64-baseline ;; + ARM64) ARCH=aarch64 ;; + *) exit 0 ;; + esac + echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-${ARCH}.zip" >> "$GITHUB_OUTPUT" - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a3a1a2d13f..e7a2b8cf6f2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,6 +63,24 @@ jobs: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}- turbo-${{ runner.os }}- + - name: Cache npm registry packages + # Plugin integration tests install @opencode-ai/plugin into fresh temp dirs + # using @npmcli/arborist, which fetches from the npm registry and caches + # tarballs in ~/.npm. Without this cache, each test does a full network + # download, easily hitting the 30 s bun test timeout on Blacksmith ARM64. + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ runner.arch }}-opencode-ai-plugin-${{ hashFiles('**/package.json') }} + restore-keys: | + npm-${{ runner.os }}-${{ runner.arch }}-opencode-ai-plugin- + + - name: Warm npm cache for @opencode-ai/plugin + # Pre-populate ~/.npm so arborist can satisfy @opencode-ai/plugin from + # cache during plugin integration tests instead of hitting the registry. + if: runner.os == 'Linux' + run: npm install --prefix /tmp/plugin-warmup @opencode-ai/plugin + - name: Run unit tests run: bun turbo test:ci env: diff --git a/.husky/pre-push b/.husky/pre-push index 5d3cc53411be..44cf5e8c1e1d 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,11 @@ #!/bin/sh set -e + +# Run prek hooks (if installed) +if command -v prek >/dev/null 2>&1; then + prek run --stage pre-push +fi + # Check if bun version matches package.json # keep in sync with packages/script/src/index.ts semver qualifier bun -e ' diff --git a/.opencode/sessions/chore-bolster-install.md b/.opencode/sessions/chore-bolster-install.md new file mode 100644 index 000000000000..a5187bdfd1c8 --- /dev/null +++ b/.opencode/sessions/chore-bolster-install.md @@ -0,0 +1,80 @@ +# Session: Bolster Install — Blacksmith ARM64 CI Fixes + +**Branch**: chore/bolster-install +**Issue**: N/A +**Created**: 2026-04-22 +**Status**: complete — PR #11 open, awaiting merge + +## Goal +Harden the `install-flex` automated installer and fix all Blacksmith ARM64 CI +failures that were blocking the unit and e2e test jobs on the Flexion fork. + +## Approach +Fix issues in layers as they surfaced during CI runs: +1. Add `install-flex` installer script +2. Pin `OPENCODE_CHANNEL=flex` to prevent per-branch SQLite DB fragmentation +3. Fix missing build tools on Blacksmith ARM64 runners +4. Fix unit test timeouts caused by arborist npm installs during tests +5. Bump individual test timeouts that are tight on ARM64 + +## Session Log +- 2026-04-22: Session created +- 2026-04-22: Added `install-flex` script (already existed on branch), fixed DB fragmentation +- 2026-04-22: CI round 1 — fixed `unzip` missing (setup-bun) +- 2026-04-22: CI round 2 — fixed `make`/`g++` missing (build-essential for node-gyp) +- 2026-04-22: CI round 3 — 7 test timeouts; root-caused to `@npmcli/arborist.reify()` in tests +- 2026-04-22: CI round 4 — 1 remaining timeout; fixed shell-loop test 3s → 15s +- 2026-04-22: All CI jobs passing. PR updated. +- 2026-04-22: CI round 5 — 1 new timeout; "shell rejects with BusyError when loop running" 3s → 15s + +## Key Decisions + +### `OPENCODE_CHANNEL=flex` in `install-flex` +OpenCode bakes `InstallationChannel` from the git branch at build time and uses it +as the SQLite DB name suffix (`opencode-.db`). Without pinning, each +rebuild from a different branch creates a fresh empty database, losing all session +history. Pinning to `"flex"` ensures all Flexion builds share `opencode-flex.db`. +See: `packages/opencode/src/storage/db.ts:getChannelPath()`. + +### `OPENCODE_DISABLE_PLUGIN_DEPS_INSTALL` flag +`config.ts` fires a background `@npmcli/arborist.reify()` for `@opencode-ai/plugin` +in every `.opencode/` directory it discovers. In tests, 7 tests were timing out +because: plugin/tool tests called `waitForDependencies()` which joined the arborist +fiber (10–30 s per test on ARM64), and the resulting CPU saturation starved +concurrent session/snapshot tests. The flag skips the install in tests; safe because +bun resolves `@opencode-ai/plugin` from the workspace `node_modules` directly. +Set unconditionally in `test/preload.ts`. + +### Blacksmith ARM64 runner gaps +`blacksmith-4vcpu-ubuntu-2404` uses ARM64 and ships a minimal Ubuntu image missing: +- `unzip` — needed by `oven-sh/setup-bun@v2` to extract the downloaded bun zip +- `make`/`g++` (build-essential) — needed by `node-gyp` for `tree-sitter-powershell` +Both now installed in a single `Ensure build tools are available` step in +`.github/actions/setup-bun/action.yml` (Linux only, no-op if already present). + +### Test timeout bumps +- `snapshot.test.ts` "revert handles large mixed batches": 30 s → 60 s + (280 files + multiple git commits/patches/reverts on ARM64) +- `prompt-effect.test.ts` "loop waits while shell runs": 3 s → 15 s + (spawns a real `sleep 0.2` subprocess; ARM64 fork/exec overhead exceeds 3 s) +- `prompt-effect.test.ts` "shell rejects with BusyError when loop running": 3 s → 15 s + (fiber fork + session init before `llm.wait(1)` exceeds 3 s on ARM64) + +## Files Changed +- `install-flex` — `OPENCODE_CHANNEL=flex` added to build command +- `.github/actions/setup-bun/action.yml` — build tools prereq + ARM64/X64 URL construction +- `.github/workflows/test.yml` — npm cache + pre-warm step (unit job) +- `packages/opencode/src/flag/flag.ts` — `OPENCODE_DISABLE_PLUGIN_DEPS_INSTALL` flag +- `packages/opencode/src/config/config.ts` — guard arborist install with new flag +- `packages/opencode/test/preload.ts` — set `OPENCODE_DISABLE_PLUGIN_DEPS_INSTALL=true` +- `packages/opencode/test/snapshot/snapshot.test.ts` — 60 s timeout on 280-file test +- `packages/opencode/test/session/prompt-effect.test.ts` — 15 s timeout on shell-loop test + +## Side Effects Applied Outside the Repo +- `~/.opencode/bin/opencode` — rebuilt from this branch with `OPENCODE_CHANNEL=flex`; + now reports `0.0.0-flex-` and uses `~/.local/share/opencode/opencode-flex.db` + +## Next Steps +- [ ] Merge PR #11 into flex: https://github.com/flexion/opencode/pull/11 +- [ ] After merge, other developers run `install-flex` to pick up all fixes +- [ ] Consider periodically running `install-flex` to stay current with `flex` branch diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000000..72535793a9bd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + # Standard pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + exclude: 'tsconfig\.json$|\.oxlintrc\.json$' + - id: check-toml + - id: check-added-large-files + args: [ "--maxkb=1000" ] + - id: detect-aws-credentials + args: [ '--allow-missing-credentials' ] + + # Detect secrets with GitLeaks + - repo: https://github.com/zricethezav/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks-docker + + # Lint GitHub Actions + - repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint-docker + + - repo: https://github.com/lalten/check-gha-pinning + rev: v1.3.1 + hooks: + - id: check-gha-pinning + + # Block forbidden files (.env, etc.) + - repo: local + hooks: + - id: block-env-files + name: Block .env files + entry: scripts/block-env-files.sh + language: script + pass_filenames: false + always_run: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ae3fc6f2fb5..fb5383184d71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,46 @@ https://github.com/anomalyco/models.dev bun dev ``` +### Setting Up Pre-Commit Hooks + +Pre-commit hooks automatically validate code before pushing. This includes: +- Secret detection (GitLeaks) +- GitHub Actions linting (actionlint, GHA pinning) +- Standard checks (trailing whitespace, JSON/YAML validation, etc.) +- `.env` file protection + +We use [prek](https://prek.j178.dev/), a fast Rust-based drop-in replacement for pre-commit. + +To install prek: + +```bash +# Using uv (recommended) +uv tool install prek + +# Using pip +pip install prek + +# Using Homebrew +brew install prek + +# Or via standalone installer +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prek/releases/latest/download/prek-installer.sh | sh +``` + +Then install the git hooks: + +```bash +prek install +``` + +This integrates prek into the `pre-push` Husky hook. Hooks will run automatically when you push. To manually run: + +```bash +prek run --stage pre-push # Run all hooks +prek run --stage pre-push --files # Run on specific file +prek run gitleaks-docker --stage pre-push # Run single hook +``` + ### Running against a different directory By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository: diff --git a/LOCAL_AWS_SETUP.md b/LOCAL_AWS_SETUP.md new file mode 100644 index 000000000000..52f71e071873 --- /dev/null +++ b/LOCAL_AWS_SETUP.md @@ -0,0 +1,90 @@ +# Local Build & AWS Bedrock Setup + +Instructions for building and running the Flexion fork of opencode. + +## AWS Credentials Setup + +AWS credentials and opencode configuration are managed by **flexcamp-ai**: + +``` +https://github.com/flexion/flexcamp-ai +``` + +Follow the setup instructions there before building or running this fork. flexcamp-ai handles AWS authentication, the `opencode-work` shell function, and `~/.config/opencode/opencode.json`. + +--- + +## Prerequisites (build only) + +- [Bun](https://bun.sh) v1.3+ +- Git + SSH key configured for GitHub (with access to the `flexion` org) + +## Clone & Build + +```bash +git clone git@github.com:flexion/opencode.git +cd opencode + +# Switch to the Flexion customizations branch +git checkout flex + +# Install dependencies +# Note: if your global ~/.npmrc redirects to a private registry (e.g. CMS Artifactory), +# override it so public packages resolve correctly: +BUN_CONFIG_REGISTRY=https://registry.npmjs.org bun install + +# Build for your current platform only +BUN_CONFIG_REGISTRY=https://registry.npmjs.org bun run --cwd packages/opencode build --single --skip-embed-web-ui +``` + +The binary will be at: +- macOS ARM64: `packages/opencode/dist/opencode-darwin-arm64/bin/opencode` +- macOS x64: `packages/opencode/dist/opencode-darwin-x64/bin/opencode` +- Linux ARM64: `packages/opencode/dist/opencode-linux-arm64/bin/opencode` +- Linux x64: `packages/opencode/dist/opencode-linux-x64/bin/opencode` + +Verify the build: + +```bash +./packages/opencode/dist/opencode-darwin-arm64/bin/opencode --version +``` + +> **Note on model config:** Config keys in `opencode.json` must match the snapshot model ID exactly (e.g. `writer.palmyra-x5-v1:0`) — the `us.` cross-region inference profile prefix is added automatically at runtime for supported models and regions. +> +> **`tool_call: false`:** Models marked with `tool_call: false` do not support tool use in streaming mode on Bedrock. This prevents opencode from sending tool definitions to those models. +> +> **`reasoning: false`:** Models marked with `reasoning: false` will have reasoning content stripped from message history before being sent to the model. Required for models like DeepSeek R1 on Bedrock that generate reasoning output but reject it as input in subsequent turns. + +## Keeping the Fork Up to Date + +When upstream releases a new version, sync `dev` and rebase `flex`: + +```bash +git fetch upstream # upstream = https://github.com/anomalyco/opencode.git +git checkout dev +git reset --hard upstream/dev +git push origin dev --force + +git checkout flex +git rebase dev +# Resolve any conflicts, then: +git push origin flex --force +``` + +See [flexion/opencode#2](https://github.com/flexion/opencode/pull/2) for the full list of Flexion customizations and conflict resolution notes. + +## What's Different in This Fork (`flex` branch) + +| Change | File(s) | Description | +|--------|---------|-------------| +| Hide skill prompt text from chat UI | `packages/opencode/src/session/prompt.ts` | Marks skill template as `synthetic` so the full prompt is sent to the model but hidden from the user | +| Respect `tool_call: false` at runtime | `packages/opencode/src/session/llm.ts` | Gates tool resolution behind `capabilities.toolcall` — fixes failures on Bedrock models that don't support streaming + tool use | +| Re-sign macOS binaries after build | `packages/opencode/script/build.ts` | Strips Bun's embedded signature and applies a fresh ad-hoc one — fixes SIGKILL (exit 137) on Darwin 25+ where Bun's signature format is rejected | +| Add inference profile prefixes for palmyra and pixtral | `packages/opencode/src/provider/provider.ts` | Adds `us.` cross-region inference profile prefix for Writer Palmyra and Mistral Pixtral models in US regions | +| Strip reasoning from history for non-reasoning models | `packages/opencode/src/provider/transform.ts` | Removes reasoning content parts from assistant message history before sending to models with `reasoning: false` — fixes Bedrock rejections when switching from a reasoning model | +| Exclude palmyra from reasoning variant generation | `packages/opencode/src/provider/transform.ts` | Prevents unsupported `reasoningConfig` parameters from being sent to Writer Palmyra models | +| Local build & AWS Bedrock setup docs | `LOCAL_AWS_SETUP.md` | This file | + +Full details and upstream tracking: [flexion/opencode#2](https://github.com/flexion/opencode/pull/2) + +Upstream issue: [anomalyco/opencode#19966](https://github.com/anomalyco/opencode/issues/19966) diff --git a/install-flex b/install-flex new file mode 100755 index 000000000000..7f69bd8522e1 --- /dev/null +++ b/install-flex @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# install-flex — Flexion fork of opencode, automated installer +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/flexion/opencode/flex/install-flex | bash +# +# What this does: +# 1. Checks prerequisites (git, bun) +# 2. Prompts: clone directory +# 3. Clones git@github.com:flexion/opencode.git (flex branch) — skipped if already present +# 4. Installs dependencies and builds the native binary +# 5. Appends opencode-work() launcher function to ~/.zshrc or ~/.bashrc +# +# AWS credentials and opencode configuration are managed by flexcamp-ai: +# https://github.com/flexion/flexcamp-ai +# Run flexcamp-ai setup before or after this installer. +# +# Existing files are never overwritten; each step is skipped with a warning +# if the output already exists. + +set -euo pipefail + +# ── colors ──────────────────────────────────────────────────────────────────── +if [ -t 1 ]; then + BOLD=$'\033[1m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' + BLUE=$'\033[0;34m'; RED=$'\033[0;31m'; NC=$'\033[0m' +else + BOLD=''; GREEN=''; YELLOW=''; BLUE=''; RED=''; NC='' +fi + +# ── constants ───────────────────────────────────────────────────────────────── +REPO_URL="git@github.com:flexion/opencode.git" +BRANCH="flex" + +# ── helpers ─────────────────────────────────────────────────────────────────── +info() { printf '%s▶%s %s\n' "$BLUE" "$NC" "$*"; } +success() { printf '%s✔%s %s\n' "$GREEN" "$NC" "$*"; } +warn() { printf '%s⚠%s %s\n' "$YELLOW" "$NC" "$*"; } +die() { printf '%s✖%s %s\n' "$RED" "$NC" "$*" >&2; exit 1; } + +# ── step 1: prerequisites ───────────────────────────────────────────────────── +check_prereqs() { + info "Checking prerequisites..." + local missing=() + command -v git >/dev/null 2>&1 || missing+=("git") + command -v bun >/dev/null 2>&1 || missing+=("bun (https://bun.sh)") + if [ ${#missing[@]} -gt 0 ]; then + die "Missing prerequisites:$(printf '\n • %s' "${missing[@]}")" + fi + success "Prerequisites OK" +} + +# ── step 2: gather inputs ───────────────────────────────────────────────────── +# All reads use /dev/tty so prompts work when stdin is the script (curl | bash). +gather_inputs() { + printf '\n%sFlexion opencode installer%s\n' "$BOLD" "$NC" + printf '────────────────────────────────────────────\n' + + printf 'Clone directory [%s]: ' "$HOME/opencode" >/dev/tty + IFS= read -r CLONE_DIR (){}!^*?'\\]*) + die "Invalid clone directory — shell metacharacters are not allowed" ;; + esac + # Reject control characters (especially newlines) + if [[ "$CLONE_DIR" =~ [[:cntrl:]] ]]; then + die "Invalid clone directory — control characters are not allowed" + fi + echo +} + +# ── step 3: clone & build ───────────────────────────────────────────────────── +clone_and_build() { + if [ -d "$CLONE_DIR/.git" ]; then + warn "Directory $CLONE_DIR already exists — skipping clone" + # Refuse to proceed over uncommitted changes to avoid silent data loss. + if ! git -C "$CLONE_DIR" diff --quiet || ! git -C "$CLONE_DIR" diff --cached --quiet; then + die "$CLONE_DIR has uncommitted changes — commit or stash them and re-run" + fi + local current + current=$(git -C "$CLONE_DIR" rev-parse --abbrev-ref HEAD) + if [ "$current" != "$BRANCH" ]; then + die "$CLONE_DIR is on branch '$current', not '$BRANCH' — switch manually and re-run" + fi + info "Fetching latest $BRANCH branch..." + git -C "$CLONE_DIR" fetch origin + git -C "$CLONE_DIR" merge --ff-only "origin/$BRANCH" + else + info "Cloning $REPO_URL → $CLONE_DIR ..." + git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$CLONE_DIR" + fi + + info "Installing dependencies..." + # BUN_CONFIG_REGISTRY override prevents private registry redirects (e.g. CMS Artifactory) + # from breaking public package resolution. + # --frozen-lockfile ensures bun.lock is respected and no unexpected version upgrades occur. + (cd "$CLONE_DIR" && BUN_CONFIG_REGISTRY=https://registry.npmjs.org bun install --frozen-lockfile) + + info "Building opencode binary (this takes about a minute)..." + # OPENCODE_CHANNEL=flex ensures every Flexion fork build — regardless of which git + # branch is checked out — bakes "flex" into the binary as the installation channel. + # This means all Flexion builds share a single ~/.local/share/opencode/opencode-flex.db + # instead of creating a new, empty database per branch (e.g. opencode-chore-bolster-install.db). + BUN_CONFIG_REGISTRY=https://registry.npmjs.org OPENCODE_CHANNEL=flex \ + bun run --cwd "$CLONE_DIR/packages/opencode" build --single --skip-embed-web-ui + + success "Binary built" +} + +# ── step 4: shell launcher function ────────────────────────────────────────── +write_shell_alias() { + local rc_file + case "$(basename "${SHELL:-bash}")" in + zsh) rc_file="$HOME/.zshrc" ;; + *) rc_file="$HOME/.bashrc" ;; + esac + + # Sentinel comment is the idempotency guard — more reliable than matching the + # function signature, which a user might remove while leaving a stale comment. + if grep -qF "── Flexion opencode launcher ──" "$rc_file" 2>/dev/null; then + warn "opencode-work() already defined in $rc_file — skipping" + return + fi + + info "Appending opencode-work() to $rc_file..." + + # Use printf %q to shell-quote the clone path at install time. + # This eliminates the previous Perl s|...|..| substitution which was vulnerable + # to injection if CLONE_DIR contained a | character. + local clone_dir_q + clone_dir_q=$(printf '%q' "$CLONE_DIR") + + # Double-quoted heredoc: $clone_dir_q expands now (install time); + # all other $ references are \-escaped so they expand at shell run time. + cat >>"$rc_file" < - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkDetach, - ) - deps.push(dep) + if (!Flag.OPENCODE_DISABLE_PLUGIN_DEPS_INSTALL) { + const dep = yield* npmSvc + .install(dir, { + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], + }) + .pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + log.warn("background dependency install failed", { dir, error: String(exit.cause) }) + }) + : Effect.void, + ), + Effect.asVoid, + Effect.forkDetach, + ) + deps.push(dep) + } result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d1391e..72d88dd00ffb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -281,7 +281,8 @@ function custom(dep: CustomDep): Record { const awsBearerToken = iife(() => { const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK if (envToken) return envToken - if (auth?.type === "api") { + // Only treat stored auth key as a bearer token when no IAM credentials exist. + if (auth?.type === "api" && !awsAccessKeyId && !profile) { process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key return auth.key } @@ -347,6 +348,8 @@ function custom(dep: CustomDep): Record { "nova-2", "claude", "deepseek", + "palmyra", + "pixtral", ].some((m) => modelID.includes(m)) const isGovCloud = region.startsWith("us-gov") if (modelRequiresPrefix && !isGovCloud) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2fa7649c75f9..13fa299692e3 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -50,6 +50,15 @@ function normalizeMessages( model: Provider.Model, _options: Record, ): ModelMessage[] { + // Strip reasoning parts from assistant messages for models that don't support reasoning + if (!model.capabilities.reasoning) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => (part as any).type !== "reasoning") + return { ...msg, content: filtered } + }) + } + // 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") { @@ -93,6 +102,30 @@ function normalizeMessages( .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } + // Strip reasoning parts that have no valid provider signature — they cannot be sent + // as thinking blocks to Anthropic without a signature and would cause a 400 error. + // This is a defence-in-depth guard; the primary prevention is in message-v2.ts where + // reasoning parts from a different model are skipped before reaching this point. + // Only applies to reasoning-capable models: non-reasoning models don't produce signed + // thinking blocks, so their reasoning parts don't need a signature. + if ( + model.capabilities.reasoning && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") + ) { + msgs = msgs + .map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg + const filtered = msg.content.filter((part) => { + if ((part as any).type !== "reasoning") return true + const opts = (part as any).providerOptions?.anthropic + return opts?.signature != null || opts?.redactedData != null + }) + if (filtered.length === 0) return undefined + return { ...msg, content: filtered } + }) + .filter((msg): msg is ModelMessage => msg !== undefined) + } + if (model.api.id.includes("claude")) { const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") msgs = msgs.map((msg) => { @@ -452,7 +485,8 @@ export function variants(model: Provider.Model): Record x !== "invalid"), - tools, - toolChoice: input.toolChoice, + ...(canTool + ? { + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + } + : {}), + ...(canTool ? { toolChoice: input.toolChoice } : {}), maxOutputTokens: params.maxOutputTokens, abortSignal: input.abort, headers: { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5f97074b20c0..4e24ed5c5984 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -745,7 +745,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const supportsMediaInToolResults = (() => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return false if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c5997..f075c79006be 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -442,6 +442,13 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + // Some reasoning models (e.g. DeepSeek R1) emit chain-of-thought inside + // tags in the text stream rather than as dedicated + // reasoning events. Strip those blocks so they don't appear as visible + // text in the UI — they are stored separately via reasoning-start/delta. + if (ctx.model.capabilities.reasoning) { + ctx.currentText.text = ctx.currentText.text.replace(/[\s\S]*?<\/think>\s*/g, "").trimStart() + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb822ff17e8b..dcd7714ab82c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -127,7 +127,7 @@ export const layer = Layer.effect( const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context - const parts: Types.DeepMutable = [{ type: "text", text: template }] + const parts: Types.DeepMutable = [{ type: "text", text: template, synthetic: true }] const files = ConfigMarkdown.files(template) const seen = new Set() yield* Effect.forEach( @@ -1601,7 +1601,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the prompt: templateParts.find((y) => y.type === "text")?.text ?? "", }, ] - : [...templateParts, ...(input.parts ?? [])] + : [ + ...templateParts, + { + type: "text" as const, + text: `Running skill: ${input.command}`, + ignored: true, + }, + ...(input.parts ?? []), + ] const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName const userModel = isSubtask diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f518a6..50f0b5cc3e48 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -45,6 +45,12 @@ process.env["OPENCODE_TEST_HOME"] = testHome const testManagedConfigDir = path.join(dir, "managed") process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true" +// Skip the background @npmcli/arborist install of @opencode-ai/plugin into +// .opencode/ dirs. Tests don't need the runtime npm install because bun +// resolves @opencode-ai/plugin from the monorepo workspace node_modules. +// Without this, plugin/tool tests trigger a full npm network fetch per test, +// consuming 10-30 s on Blacksmith ARM64 CI and causing timeouts. +process.env["OPENCODE_DISABLE_PLUGIN_DEPS_INSTALL"] = "true" // Write the cache version file to prevent global/index.ts from clearing the cache const cacheDir = path.join(dir, "cache", "opencode") diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be82e..5ca4146f2f97 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -561,6 +561,95 @@ describe("session.llm.stream", () => { }) }) + test("does not send tools when model toolcall is disabled", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-no-tools") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-no-tools"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: { + ...resolved, + capabilities: { + ...resolved.capabilities, + toolcall: false, + }, + }, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + question: tool({ + description: "Ask a question", + inputSchema: z.object({}), + execute: async () => ({ output: "" }), + }), + }, + }) + + const capture = await request + expect(capture.body.tools).toBeUndefined() + expect(capture.body.tool_choice).toBeUndefined() + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b85d570dc55b..c64e397f5fcb 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1528,4 +1528,4 @@ test("revert handles large mixed batches across chunk boundaries", async () => { ) }, }) -}) +}, 60_000) // 280 files + multiple git operations can exceed 30 s on slower ARM64 CI runners diff --git a/scripts/block-env-files.sh b/scripts/block-env-files.sh new file mode 100755 index 000000000000..6a506fb221e8 --- /dev/null +++ b/scripts/block-env-files.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Block .env files from being committed +if git diff --cached --name-only | grep -qE '^\.(env|env\..*)$'; then + echo "Error: .env files cannot be committed" + exit 1 +fi