diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index c46a21a51db0..5da940f7ca68 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -92,6 +92,16 @@ You are a senior engineer conducting a thorough code review. Review **only the l - **AnimatePresence**: Is it used properly with unique keys for dialog/modal transitions? - **Reduced Motion**: Is `useReducedMotion()` respected for accessibility? +### Async State, Defaults & Persistence +- **Async Source of Truth**: During async provider/model/session mutations, does UI/session/localStorage state update only after the backend accepts the change? If the UI updates optimistically, is there an explicit rollback path? +- **UI/Backend Drift**: Could the UI show provider/model/project/persona X while the backend is still on Y after a failed mutation, delayed prepare, or pending-to-real session handoff? +- **Requested vs Fallback Authority**: Do explicit user or caller selections stay authoritative over sticky defaults, saved preferences, aliases, or fallback resolution? +- **Dependent State Invalidation**: When a parent selection changes (provider/project/persona/workspace/etc.), are dependent values like `modelId`, `modelName`, defaults, or cached labels cleared or recomputed so stale state does not linger? +- **Persisted Preference Validation**: Are stored selections validated against current inventory/capabilities before reuse, and do stale values fail soft instead of breaking creation flows? +- **Compatibility of Fallbacks**: Are default or sticky selections guaranteed to remain compatible with the active concrete provider/backend, instead of leaking across providers? +- **Best-Effort Lookups**: Do inventory/config/default-resolution lookups degrade gracefully on transient failure, or can they incorrectly block a primary flow that should still work with a safe fallback? +- **Draft/Home/Handoff Paths**: If the product has draft, Home, pending, or pre-created sessions, did you review those handoff paths separately from the already-active session path? + ### General Code Quality - **Error Handling**: Are errors handled gracefully with user-friendly messages? - **Loading States**: Are loading states shown during async operations? @@ -104,13 +114,18 @@ You are a senior engineer conducting a thorough code review. Review **only the l ### Step 0: Run Quality Checks -Before reading any code, run the project's CI gate to establish a baseline: +Before reading any code, run the project's CI gate to establish a baseline. Use **check-only** commands so the baseline never mutates the working tree — otherwise auto-formatters can introduce unstaged diffs and you'll end up reviewing formatter output instead of the author's actual changes. + +Avoid `just check-everything` as the baseline in this repo: that recipe runs `cargo fmt --all` in write mode and will modify the working tree. Run the non-mutating equivalents instead: ```bash -just ci +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +(cd ui/desktop && pnpm run lint:check) +./scripts/check-openapi-schema.sh ``` -This runs: `pnpm check` (Biome lint/format + file sizes), `pnpm typecheck`, `just clippy` (Rust linting), `pnpm test`, `pnpm build`, and `just tauri-check` (Rust type checking). +If the project has a stronger pre-push or CI gate than this helper set, run that fuller gate when the review is meant to be PR-ready, but only after confirming it is also non-mutating (or run it from a clean stash). In this repo, targeted tests for the changed area plus the pre-push checks are often the practical follow-up. Report the results as pass/fail. Any failures are automatically **P0** issues and should appear at the top of the findings list. Do not skip this step even if the user only wants a quick review. @@ -120,7 +135,8 @@ For each file in the list: 1. Run `git diff main...HEAD -- ` to get the exact lines that changed 2. Review **only those changed lines** against the Review Checklist — do not flag issues in unchanged code -3. Note the file path and line numbers from the diff output for each issue found +3. For stateful UI or async flow changes, trace the full path end to end: user selection -> local/session state update -> persistence -> backend prepare/set/update call -> failure/rollback path +4. Note the file path and line numbers from the diff output for each issue found ### Step 2: Categorize Issues @@ -152,16 +168,17 @@ After reviewing all files, provide: ### Step 3b: Self-Check -Before presenting findings to the user, silently review the issue list two more times: +Before presenting findings to the user, silently review the issue list three times: 1. **Pass 1**: For each issue, ask — is this genuinely a problem, or could it be intentional/acceptable? Remove false positives. 2. **Pass 2**: For each remaining issue, ask — does the recommended fix actually improve the code, or is it a matter of preference? +3. **Pass 3**: For async state/default-resolution issues, ask — can the UI, persisted state, and backend ever disagree after a failure, fallback, or session handoff? -After both passes, tag each surviving issue as one of: +After these passes, tag each surviving issue as one of: - **[Must Fix]** — clear violation, will likely get flagged in PR review - **[Your Call]** — valid concern but may be intentional or a reasonable tradeoff (e.g. stepping outside the design system for a specific reason). Present it but let the user decide. -Only present issues that survived both passes. +Only present issues that survived these passes. ### Step 4: Fix Issues @@ -189,7 +206,7 @@ Once all issues are fixed, display: **✅ Code review complete! All issues have been addressed.** -Your code is ready to commit and push. Lefthook will run the full CI gate (`just ci`) automatically when you push. +Your code is ready to commit and push. Lefthook and CI will run the repo's configured gates when you push. Next steps: generate a PR summary that explains the intent of this change, what files were modified and why, and how to verify the changes work. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index d95ef3c462ab..000000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,6 +0,0 @@ -# CODEOWNERS file for aaif-goose/goose repository -# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners - -# Documentation owned by DevRel -/documentation/ @blackgirlbytes @angiejones @DOsinga - diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..25f111e03732 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + # pnpm workspace for the UI (desktop, acp, text, sdk, goose-binary/*, goose2). + # Point at the workspace ROOT where pnpm-lock.yaml lives so Dependabot updates + # both the child package.json AND ui/pnpm-lock.yaml in one PR. + - package-ecosystem: "npm" + directory: "/ui" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + ui-minor-and-patch: + update-types: + - "minor" + - "patch" + + # Cargo workspace at the repo root. + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + cargo-minor-and-patch: + update-types: + - "minor" + - "patch" + + # GitHub Actions used by workflows in .github/workflows. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/bundle-goose2-manual.yml b/.github/workflows/bundle-goose2-manual.yml new file mode 100644 index 000000000000..8655287054ed --- /dev/null +++ b/.github/workflows/bundle-goose2-manual.yml @@ -0,0 +1,26 @@ +name: "Manual Goose 2 Bundle (Unsigned)" + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch name to bundle app from" + required: true + type: string + cli-run-id: + description: "Run ID of a build-cli workflow to pull the goose binary from (optional, builds from source if empty)" + required: false + type: string + default: "" + +jobs: + bundle-goose2: + uses: ./.github/workflows/bundle-goose2.yml + permissions: + id-token: write + contents: read + actions: read + with: + signing: false + ref: ${{ inputs.branch }} + cli-run-id: ${{ inputs.cli-run-id }} diff --git a/.github/workflows/bundle-goose2.yml b/.github/workflows/bundle-goose2.yml new file mode 100644 index 000000000000..814ffbfa80ad --- /dev/null +++ b/.github/workflows/bundle-goose2.yml @@ -0,0 +1,771 @@ +# Reusable workflow that bundles the Goose 2 (Tauri) desktop app. +# Produces .app / .dmg on macOS and .deb / .AppImage on Linux. +# +# The justfile recipe is: `just bundle` → `pnpm tauri build` +# +# Called from release.yml, canary.yml, or manually via bundle-goose2-manual.yml. +# +# The goose CLI binary can either be built from source (default) or pulled +# from a prior build-cli.yml run by passing `cli-run-id`. This avoids +# redundant ~20min Rust builds during release pipelines. +name: "Bundle Goose 2 Desktop" + +on: + workflow_call: + inputs: + version: + description: "Version to set for the build (leave empty to use Cargo.toml default)" + required: false + default: "" + type: string + signing: + description: "Whether to perform Apple signing and notarization (macOS only)" + required: false + default: false + type: boolean + quick_test: + description: "Whether to perform the quick launch test (macOS only)" + required: false + default: true + type: boolean + ref: + description: "Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref." + required: false + default: "" + type: string + environment: + description: "GitHub Environment containing signing secrets. Leave empty to skip." + required: false + default: "" + type: string + windows-signing: + description: "Whether to perform Windows signing via Azure Trusted Signing" + required: false + default: false + type: boolean + cli-run-id: + description: > + Run ID of a prior build-cli.yml workflow run to download the goose + binary from. When empty, the goose CLI is built from source. + required: false + default: "" + type: string + +jobs: + # ─────────────────────────────────────────────── + # macOS ARM (Apple Silicon) + # ─────────────────────────────────────────────── + bundle-macos-arm: + name: "macOS ARM64" + runs-on: macos-latest + environment: ${{ inputs.environment || '' }} + timeout-minutes: 60 + env: + MACOSX_DEPLOYMENT_TARGET: "12.0" + permissions: + id-token: write + contents: read + actions: read + outputs: + artifact-url: ${{ steps.upload.outputs.artifact-url }} + steps: + - name: Debug workflow info + env: + INPUT_REF: ${{ inputs.ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_SIGNING: ${{ inputs.signing }} + INPUT_CLI_RUN_ID: ${{ inputs.cli-run-id }} + run: | + echo "=== Goose 2 Bundle (macOS ARM64) ===" + echo "Ref: ${INPUT_REF:-}" + echo "Version: ${INPUT_VERSION:-}" + echo "Signing: ${INPUT_SIGNING}" + echo "CLI run ID: ${INPUT_CLI_RUN_ID:-}" + df -h + + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + # ── Version stamps ── + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + # Root Cargo.toml (workspace version) + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + + # Tauri Cargo.toml + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + + # package.json + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-aarch64-apple-darwin + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TRIPLE="aarch64-apple-darwin" + mkdir -p target/release + # The artifact contains goose-.tar.bz2 with goose inside + tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: goose2-macos-arm64 + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TRIPLE=$(rustc --print host-tuple) + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + # ── Frontend: pnpm install + SDK build ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-macos-arm64-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-macos-arm64-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Apple signing ── + - name: Import Apple signing certificate + if: inputs.signing + uses: ./.github/actions/apple-codesign + with: + certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + # ── Tauri bundle ── + - name: Bundle Goose 2 (pnpm tauri build) + env: + APPLE_SIGNING_IDENTITY: ${{ inputs.signing && 'Developer ID Application' || '' }} + APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }} + APPLE_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }} + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build + + - name: Clean up signing keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload Goose 2 macOS ARM64 artifact + id: upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-darwin-arm64 + path: | + ui/goose2/src-tauri/target/release/bundle/dmg/*.dmg + ui/goose2/src-tauri/target/release/bundle/macos/*.app + if-no-files-found: error + + # ── Smoke test ── + - name: Quick launch test + if: inputs.quick_test + run: | + APP_PATH=$(find ui/goose2/src-tauri/target/release/bundle/macos -maxdepth 1 -name "*.app" | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app found, skipping launch test" + exit 0 + fi + + xattr -cr "$APP_PATH" + echo "Opening $APP_PATH..." + open -g "$APP_PATH" + sleep 5 + + if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then + echo "✅ App is running" + else + echo "❌ App did not stay open" + exit 1 + fi + + pkill -f "Goose.app/Contents/MacOS" || true + + # ─────────────────────────────────────────────── + # macOS Intel (x86_64) + # ─────────────────────────────────────────────── + bundle-macos-intel: + name: "macOS x86_64" + runs-on: macos-latest + environment: ${{ inputs.environment || '' }} + timeout-minutes: 60 + env: + MACOSX_DEPLOYMENT_TARGET: "12.0" + permissions: + id-token: write + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-apple-darwin + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TARGET="x86_64-apple-darwin" + mkdir -p target/release + tar -xjf "cli-artifact/goose-${TARGET}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TARGET}" + ls -lh "target/release/goose-${TARGET}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: goose2-macos-x86_64 + + - name: Install Intel target for both toolchains + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + rustup target add x86_64-apple-darwin + cd ui/goose2/src-tauri + rustup target add x86_64-apple-darwin + + - name: Build goose CLI for Intel (x86_64-apple-darwin) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TARGET="x86_64-apple-darwin" + mkdir -p target/release + cp "target/${TARGET}/release/goose" "target/release/goose-${TARGET}" + ls -lh "target/release/goose-${TARGET}" + + # ── Intel target still needed for Tauri's own Rust build ── + - name: Install Intel target for Tauri toolchain + run: | + source ./bin/activate-hermit + rustup target add x86_64-apple-darwin + cd ui/goose2/src-tauri + rustup target add x86_64-apple-darwin + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-macos-intel-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-macos-intel-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Apple signing ── + - name: Import Apple signing certificate + if: inputs.signing + uses: ./.github/actions/apple-codesign + with: + certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + # ── Tauri bundle (cross-compile for Intel) ── + - name: Bundle Goose 2 for Intel + env: + APPLE_SIGNING_IDENTITY: ${{ inputs.signing && 'Developer ID Application' || '' }} + APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }} + APPLE_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }} + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build --target x86_64-apple-darwin + + - name: Clean up signing keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload Goose 2 macOS Intel artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-darwin-x64 + path: | + ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg + ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app + if-no-files-found: error + + - name: Quick launch test + if: inputs.quick_test + run: | + APP_PATH=$(find ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" 2>/dev/null | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app found, skipping launch test" + exit 0 + fi + + xattr -cr "$APP_PATH" + echo "Opening $APP_PATH..." + open -g "$APP_PATH" + sleep 5 + + if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then + echo "✅ App is running" + else + echo "❌ App did not stay open" + exit 1 + fi + + pkill -f "Goose.app/Contents/MacOS" || true + + # ─────────────────────────────────────────────── + # Linux x86_64 + # ─────────────────────────────────────────────── + bundle-linux: + name: "Linux x86_64" + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + protobuf-compiler + + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-unknown-linux-gnu + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TRIPLE="x86_64-unknown-linux-gnu" + mkdir -p target/release + tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: goose2-linux-x86_64 + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TRIPLE=$(rustc --print host-tuple) + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-linux-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-linux-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Tauri bundle ── + - name: Bundle Goose 2 (pnpm tauri build) + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null | head -30 + echo "" + echo "=== File sizes ===" + find "$BUNDLE_DIR" -type f -exec ls -lh {} \; 2>/dev/null | head -20 + + - name: Upload .deb package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-deb + path: ui/goose2/src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: warn + + - name: Upload AppImage + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-appimage + path: ui/goose2/src-tauri/target/release/bundle/appimage/*.AppImage + if-no-files-found: warn + + - name: Upload RPM package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-rpm + path: ui/goose2/src-tauri/target/release/bundle/rpm/*.rpm + if-no-files-found: warn + + # ─────────────────────────────────────────────── + # Windows x86_64 + # ─────────────────────────────────────────────── + bundle-windows: + name: "Windows x86_64" + runs-on: windows-latest + timeout-minutes: 60 + permissions: + id-token: write + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + # Hermit doesn't work on Windows — install node/pnpm directly + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + + - name: Install pnpm + run: npm install -g pnpm@10.33.0 + + - name: Update versions + if: inputs.version != '' + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-pc-windows-msvc + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + shell: bash + run: | + TARGET="x86_64-pc-windows-msvc" + mkdir -p target/release + # The zip contains goose-package/goose.exe + cd cli-artifact + 7z x "goose-${TARGET}.zip" + cd .. + cp cli-artifact/goose-package/goose.exe target/release/ + # Tauri externalBin appends target triple + .exe on Windows + cp target/release/goose.exe "target/release/goose-${TARGET}.exe" + ls -lh "target/release/goose-${TARGET}.exe" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: goose2-windows-x86_64 + + - name: Setup Rust + if: inputs.cli-run-id == '' + shell: bash + run: | + rustup show + rustup target add x86_64-pc-windows-msvc + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + shell: bash + run: | + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + shell: bash + run: | + TARGET="x86_64-pc-windows-msvc" + cp "target/${TARGET}/release/goose.exe" "target/release/goose-${TARGET}.exe" + ls -lh "target/release/goose-${TARGET}.exe" + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + key: goose2-pnpm-windows-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-windows-${{ runner.os }}- + + - name: Install pnpm dependencies + shell: bash + run: | + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + shell: bash + run: | + cd ui/sdk + pnpm build + + # ── Tauri bundle ── + - name: Bundle Goose 2 (pnpm tauri build) + shell: bash + working-directory: ui/goose2 + run: | + pnpm tauri build --target x86_64-pc-windows-msvc + + # ── Upload ── + - name: List bundle output + shell: bash + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload NSIS installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-windows-x64-nsis + path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + if-no-files-found: warn + + - name: Upload MSI installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-windows-x64-msi + path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + if-no-files-found: warn + + sign-windows: + name: "Sign Windows installers" + needs: bundle-windows + if: inputs.windows-signing + runs-on: windows-latest + environment: signing + permissions: + id-token: write + contents: read + actions: read + steps: + - name: Download NSIS installer + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-windows-x64-nsis + path: unsigned/nsis + + - name: Download MSI installer + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-windows-x64-msi + path: unsigned/msi + + - name: Azure login + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows installers with Azure Trusted Signing + uses: azure/trusted-signing-action@db7a3a6bd3912025c705162fb7475389f5b69ec6 # v1 + with: + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: ${{ github.workspace }}/unsigned + files-folder-filter: exe,msi + files-folder-recurse: true + + - name: Verify signed installers + shell: pwsh + run: | + $files = Get-ChildItem -Path unsigned -Recurse -Include *.exe,*.msi + foreach ($file in $files) { + Write-Output "Verifying signature: $($file.FullName)" + $sig = Get-AuthenticodeSignature $file.FullName + if ($sig.Status -ne "Valid") { + throw "Signature invalid for $($file.Name): $($sig.Status)" + } + Write-Output "✅ Signature valid: $($file.Name)" + } + + - name: Upload signed NSIS installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: Goose2-windows-x64-nsis-signed + path: unsigned/nsis/*.exe + if-no-files-found: error + + - name: Upload signed MSI installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: Goose2-windows-x64-msi-signed + path: unsigned/msi/*.msi + if-no-files-found: error diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 181f9466dcdc..65c1741528ce 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -143,7 +143,7 @@ jobs: # Create/update the canary release - name: Release canary - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: canary name: Canary diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index c2215b088720..e9081364f97f 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -24,6 +24,6 @@ jobs: steps: - uses: actions/checkout@v4 # https://github.com/EmbarkStudios/cargo-deny-action v2.0.15 - - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 + - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb with: command: check advisories diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db093a0c056..8cea74121f80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | @@ -100,6 +100,43 @@ jobs: env: CARGO_INCREMENTAL: "0" + rust-msrv: + name: Check MSRV + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Read MSRV from Cargo.toml + id: msrv + run: | + msrv=$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' Cargo.toml) + if [ -z "$msrv" ]; then + echo "Could not find rust-version in workspace Cargo.toml" >&2 + exit 1 + fi + echo "msrv=$msrv" >> "$GITHUB_OUTPUT" + echo "MSRV: $msrv" + + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + with: + toolchain: ${{ steps.msrv.outputs.msrv }} + + - name: Install Dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev libxcb1-dev + + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + key: msrv + + - name: Check with MSRV toolchain + run: cargo check --workspace --locked --all-targets + env: + CARGO_INCREMENTAL: "0" + rust-lint: name: Lint Rust Code runs-on: ubuntu-latest @@ -108,7 +145,7 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - uses: Swatinem/rust-cache@v2 @@ -130,7 +167,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index 37600bfe3158..8253f9888633 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: toolchain: stable diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml index 4a2d70b001fe..68a2eae8b8dd 100644 --- a/.github/workflows/goose-release-notes.yml +++ b/.github/workflows/goose-release-notes.yml @@ -170,7 +170,7 @@ jobs: steps: - name: Update release body - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: ${{ needs.generate-release-notes.outputs.release_tag }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/goose2-ci.yml b/.github/workflows/goose2-ci.yml index 12f70754ce0a..57b4f12aa16c 100644 --- a/.github/workflows/goose2-ci.yml +++ b/.github/workflows/goose2-ci.yml @@ -91,7 +91,9 @@ jobs: patchelf - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + rust-src-dir: ui/goose2 - name: Cache Rust uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 @@ -158,8 +160,9 @@ jobs: patchelf - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: + rust-src-dir: ui/goose2 components: rustfmt, clippy - name: Cache Rust diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index cdc0414ace61..f49d983dae5e 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -55,7 +55,7 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | @@ -108,9 +108,13 @@ jobs: node-version: '22' - name: Install agentic providers - run: npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli @zed-industries/claude-agent-acp @zed-industries/codex-acp + run: npm install -g @anthropic-ai/claude-code @zed-industries/claude-agent-acp @zed-industries/codex-acp - - name: Run Smoke Tests with Provider Script + - name: Install Node.js Dependencies + run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile + working-directory: ui/desktop + + - name: Run Smoke Tests (Normal Mode) env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -127,12 +131,10 @@ jobs: SKIP_BUILD: 1 SKIP_PROVIDERS: ${{ vars.SKIP_PROVIDERS || '' }} run: | - # Ensure the HOME directory structure exists mkdir -p $HOME/.local/share/goose/sessions mkdir -p $HOME/.config/goose - - # Run the provider test script (binary already built and downloaded) - bash scripts/test_providers.sh + source ../../bin/activate-hermit && pnpm run test:integration:providers + working-directory: ui/desktop - name: Set up Python uses: actions/setup-python@v5 @@ -188,6 +190,10 @@ jobs: - name: Make Binary Executable run: chmod +x target/debug/goose + - name: Install Node.js Dependencies + run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile + working-directory: ui/desktop + - name: Run Provider Tests (Code Execution Mode) env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -205,7 +211,8 @@ jobs: run: | mkdir -p $HOME/.local/share/goose/sessions mkdir -p $HOME/.config/goose - bash scripts/test_providers_code_exec.sh + source ../../bin/activate-hermit && pnpm run test:integration:providers-code-exec + working-directory: ui/desktop compaction-tests: name: Compaction Tests @@ -277,7 +284,8 @@ jobs: GOOSE_PROVIDER: anthropic GOOSE_MODEL: claude-sonnet-4-5-20250929 SHELL: /bin/bash + SKIP_BUILD: 1 run: | echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile - source ../../bin/activate-hermit && pnpm run test:integration:debug + source ../../bin/activate-hermit && pnpm run test:integration:goosed working-directory: ui/desktop diff --git a/.github/workflows/pr-website-preview.yml b/.github/workflows/pr-website-preview.yml index 41cc51164b3d..4b1e6e35426e 100644 --- a/.github/workflows/pr-website-preview.yml +++ b/.github/workflows/pr-website-preview.yml @@ -51,7 +51,7 @@ jobs: cleanup: runs-on: ubuntu-latest needs: deploy - if: github.event.action == 'closed' + if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == 'aaif-goose/goose' permissions: contents: write steps: diff --git a/.github/workflows/release-goose2.yml b/.github/workflows/release-goose2.yml new file mode 100644 index 000000000000..bc1d8e4ae766 --- /dev/null +++ b/.github/workflows/release-goose2.yml @@ -0,0 +1,192 @@ +on: + push: + tags: + - "v2.*" + workflow_dispatch: + inputs: + version: + description: "Version string (e.g. 2.0.0-rc.1). Used when testing from a branch." + required: true + type: string + cli-run-id: + description: "Run ID of a build-cli workflow to pull goose binaries from (skips CLI build step)" + required: false + type: string + default: "" + +name: "Release Goose 2" + +permissions: + id-token: write # Sigstore OIDC signing + Azure OIDC (Windows signing) + contents: write # Creating releases + actions/checkout + actions: read # Downloading artifacts across workflow runs + attestations: write # SLSA build provenance attestations + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prepare-version: + name: Prepare Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Extract version + id: set-version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + # Strip the leading "v" from the tag (e.g. v2.0.0 → 2.0.0) + VERSION="${GITHUB_REF_NAME#v}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Release version: $VERSION" + + build-cli: + if: inputs.cli-run-id == '' + needs: [prepare-version] + uses: ./.github/workflows/build-cli.yml + with: + version: ${{ needs.prepare-version.outputs.version }} + + bundle-goose2: + needs: [prepare-version, build-cli] + if: ${{ !cancelled() && needs.prepare-version.result == 'success' && (needs.build-cli.result == 'success' || needs.build-cli.result == 'skipped') }} + uses: ./.github/workflows/bundle-goose2.yml + permissions: + id-token: write + contents: read + actions: read + with: + version: ${{ needs.prepare-version.outputs.version }} + signing: true + windows-signing: true + environment: signing + cli-run-id: ${{ inputs.cli-run-id != '' && inputs.cli-run-id || github.run_id }} + secrets: inherit + + install-script: + name: Upload Install Script + runs-on: ubuntu-latest + if: inputs.cli-run-id == '' + needs: [build-cli] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: download_cli.sh + path: download_cli.sh + + release: + name: Release + runs-on: ubuntu-latest + needs: [prepare-version, build-cli, install-script, bundle-goose2] + if: ${{ !cancelled() && needs.bundle-goose2.result == 'success' }} + permissions: + contents: write + id-token: write + actions: read + attestations: write + steps: + - name: Download CLI artifacts + if: needs.build-cli.result == 'success' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: goose-* + merge-multiple: true + path: release + + - name: Download install script + if: needs.install-script.result == 'success' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: download_cli.sh + path: release + + - name: Download macOS ARM64 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-darwin-arm64 + path: release + + - name: Download macOS Intel + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-darwin-x64 + path: release + + - name: Download Linux .deb + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-linux-x64-deb + path: release + continue-on-error: true + + - name: Download Linux AppImage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-linux-x64-appimage + path: release + continue-on-error: true + + - name: Download Linux RPM + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-linux-x64-rpm + path: release + continue-on-error: true + + - name: Download signed Windows NSIS installer + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-windows-x64-nsis-signed + path: release + + - name: Download signed Windows MSI installer + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: Goose2-windows-x64-msi-signed + path: release + + - name: List downloaded artifacts + run: | + echo "=== All release artifacts ===" + find release -type f | sort + + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: | + release/goose-*.tar.bz2 + release/goose-*.tar.gz + release/goose-*.zip + release/*.dmg + release/*.exe + release/*.msi + release/*.deb + release/*.rpm + release/*.AppImage + release/download_cli.sh + + # Create/update the versioned pre-release (e.g. v2.0.0) + - name: Release versioned + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prerelease: true + artifacts: | + release/goose-*.tar.bz2 + release/goose-*.tar.gz + release/goose-*.zip + release/*.dmg + release/*.exe + release/*.msi + release/*.deb + release/*.rpm + release/*.AppImage + release/download_cli.sh + allowUpdates: true + omitBody: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c1a2671c833..60cea64628a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,7 +117,7 @@ jobs: # Create/update the versioned release - name: Release versioned - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: token: ${{ secrets.GITHUB_TOKEN }} artifacts: | @@ -135,7 +135,7 @@ jobs: # Create/update the stable release - name: Release stable - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: stable name: Stable diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8a689ff1dea9..dbe008a54e2e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -75,9 +75,6 @@ jobs: # Skip PRs with these labels (comma-separated) exempt-pr-labels: 'keep-open,wip,work-in-progress,security,pinned,dependencies' - # Skip draft PRs (they're typically work in progress) - exempt-draft-pr: true - # === ISSUE CONFIGURATION (DISABLED) === # We only want to manage PRs, not issues days-before-issue-stale: -1 diff --git a/.gitignore b/.gitignore index 23f1a062021e..851b0ea38ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ ui/desktop/src/bin/goose_llm.dll # Claude .claude/settings.local.json +.claude/scheduled_tasks.lock debug_*.txt diff --git a/.husky/pre-commit b/.husky/pre-commit index 4e793eaacb99..63b3d6bcc9f2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,39 @@ +#!/usr/bin/env bash +set -e + # Only auto-format desktop TS code if relevant files are modified -if git diff --cached --name-only | grep -q "^ui/desktop/"; then +if git diff --cached --no-renames --name-only | grep -q "^ui/desktop/"; then if [ -d "ui/desktop" ]; then - cd ui/desktop && pnpm exec lint-staged + (cd ui/desktop && pnpm exec lint-staged) else echo "Warning: ui/desktop directory does not exist, skipping lint-staged" fi fi + +# Run goose2 checks if any staged files are under ui/goose2/ +if git diff --cached --no-renames --name-only | grep -q '^ui/goose2/'; then + if [ -d "ui/goose2" ]; then + REPO_ROOT="$(pwd)" + echo "Running goose2 pre-commit checks..." + + # Auto-format only staged files that biome can process, then re-stage them. + # Exclude justfile and .swift files — biome doesn't understand these formats + # and would fail with "no files were processed" when only such files are staged. + STAGED_FILES=$(git diff --cached --no-renames --diff-filter=ACMR --name-only \ + | grep '^ui/goose2/' \ + | grep -v -E '(^ui/goose2/justfile$|\.swift$)' \ + | sed 's|^ui/goose2/||' || true) + if [ -n "$STAGED_FILES" ]; then + cd ui/goose2 + echo "$STAGED_FILES" | xargs npx biome format --write + echo "$STAGED_FILES" | xargs npx biome check --fix + cd "$REPO_ROOT" + git diff --cached --no-renames --diff-filter=ACMR --name-only | grep '^ui/goose2/' | xargs git add + fi + + # Run checks (biome check + file sizes + i18n + typecheck) + just goose2 check + else + echo "Warning: ui/goose2 directory does not exist, skipping goose2 checks" + fi +fi diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000000..7e68deb749c3 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Run goose2 pre-push checks if any commits being pushed include goose2 changes. + +# --- Helper functions --- + +# Check if any ref being pushed includes changes under a given path prefix. +# Reads git's pre-push stdin (local_ref local_oid remote_ref remote_oid). +push_touches() { + local prefix="$1" + local z40="0000000000000000000000000000000000000000" + + while read local_ref local_oid remote_ref remote_oid; do + [ "$local_oid" = "$z40" ] && continue # deleting ref + + if [ "$remote_oid" = "$z40" ]; then + # New branch — compare against the merge base with the default branch + range="$(git merge-base HEAD main 2>/dev/null || echo "$local_oid")...$local_oid" + else + range="$remote_oid...$local_oid" + fi + + if git diff --no-renames --name-only "$range" -- 2>/dev/null | grep -q "^${prefix}"; then + return 0 + fi + done + + return 1 +} + +# Run commands in parallel, exit 1 if any fail. +# Usage: run_parallel "label1" "cmd1" "label2" "cmd2" ... +run_parallel() { + local -a labels=() pids=() + while [[ $# -ge 2 ]]; do + labels+=("$1"); shift + ( eval "$1" ) & pids+=($!); shift + done + + local -a failed=() + for i in "${!pids[@]}"; do + wait "${pids[$i]}" || failed+=("${labels[$i]}") + done + + if [[ ${#failed[@]} -gt 0 ]]; then + echo "Failed: ${failed[*]}" + return 1 + fi +} + +# --- Hook body --- + +push_touches "ui/goose2/" || exit 0 + +echo "Detected ui/goose2/ changes — running pre-push checks..." + +run_parallel \ + "fmt-check" "just goose2 fmt-check" \ + "clippy" "just goose2 clippy" \ + "check" "just goose2 check" \ + "test" "just goose2 test" \ + "build" "just goose2 build" \ + "tauri-check" "just goose2 tauri-check" + +exit_code=$? + +if [ $exit_code -eq 0 ]; then + echo "All goose2 pre-push checks passed." +else + echo "goose2 pre-push checks failed." + exit 1 +fi diff --git a/AGENTS.md b/AGENTS.md index cb036c9eac70..ca6db72ed9f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,6 @@ git commit -s # required for DCO sign-off ``` crates/ ├── goose # core logic -├── goose-acp # Agent Client Protocol ├── goose-acp-macros # ACP proc macros ├── goose-cli # CLI entry ├── goose-server # backend (binary: goosed) diff --git a/BUILDING_LINUX.md b/BUILDING_LINUX.md index 4efa33040ccf..6fc0e210db37 100644 --- a/BUILDING_LINUX.md +++ b/BUILDING_LINUX.md @@ -9,7 +9,7 @@ This guide covers building the goose Desktop application from source on various **Debian/Ubuntu:** ```bash sudo apt update -sudo apt install -y dpkg fakeroot build-essential libxcb1-dev libxcb-util-dev protobuf-compiler +sudo apt install -y dpkg fakeroot build-essential clang libxcb1-dev libxcb-util-dev protobuf-compiler ``` **Arch/Manjaro:** @@ -44,6 +44,7 @@ pkg install cmake protobuf clang build-essential - **Rust**: Install via [rustup](https://rustup.rs/) - **Node.js**: Version 22.9.0 or later (use [nvm](https://github.com/nvm-sh/nvm) for version management) - **pnpm**: Version 10 or later (managed via Hermit, or install globally) +- **just**: Install via `cargo install just` after Rust is installed. More [info](https://github.com/casey/just#packages) ## Build Process @@ -53,11 +54,27 @@ git clone https://github.com/aaif-goose/goose.git cd goose ``` -### 2. Build the Rust Backend +### 2. Build + +Build Goose CLI: + +```bash +cargo build --release -p goose-cli +``` + +Build Goose Server: + ```bash cargo build --release -p goose-server ``` +This command should give you a list of possible packages in the +workspace: + +```bash +cargo test -p +``` + ### 3. Prepare the Desktop Application ```bash cd ui/desktop diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index c9f08a2241ad..d4f7c15ecb47 100644 --- a/CUSTOM_DISTROS.md +++ b/CUSTOM_DISTROS.md @@ -416,7 +416,7 @@ For the full ACP specification, see the [Agent Client Protocol documentation](ht - API client example: `ui/desktop/src/api/` (generated TypeScript client) **ACP**: -- ACP server implementation: `crates/goose-acp/src/server.rs` +- ACP server implementation: `crates/goose/src/acp/server.rs` - CLI integration: `crates/goose-cli/src/cli.rs` (Command::Acp) - Protocol library: `sacp` crate (Rust implementation of ACP) - Test client example: `test_acp_client.py` diff --git a/Cargo.lock b/Cargo.lock index 879339f3b093..55365d2f2f0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4326,7 +4326,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.31.0" +version = "1.32.0" dependencies = [ "agent-client-protocol-schema", "ahash", @@ -4358,8 +4358,12 @@ dependencies = [ "fs-err", "fs2", "futures", + "goose-acp-macros", + "goose-mcp", + "goose-sdk", "goose-test-support", "http 1.4.0", + "http-body-util", "ignore", "include_dir", "indexmap 2.13.0", @@ -4418,6 +4422,7 @@ dependencies = [ "tokio-cron-scheduler", "tokio-stream", "tokio-util", + "tower-http", "tracing", "tracing-futures", "tracing-opentelemetry", @@ -4445,45 +4450,9 @@ dependencies = [ "zip 8.4.0", ] -[[package]] -name = "goose-acp" -version = "1.31.0" -dependencies = [ - "agent-client-protocol-schema", - "anyhow", - "async-stream", - "async-trait", - "axum", - "fs-err", - "futures", - "goose", - "goose-acp-macros", - "goose-mcp", - "goose-sdk", - "goose-test-support", - "http-body-util", - "regex", - "rmcp", - "sacp", - "schemars 1.2.1", - "serde", - "serde_json", - "sqlx", - "strum 0.27.2", - "tempfile", - "test-case", - "tokio", - "tokio-util", - "tower-http", - "tracing", - "url", - "uuid", - "wiremock", -] - [[package]] name = "goose-acp-macros" -version = "1.31.0" +version = "1.32.0" dependencies = [ "quote", "syn 2.0.117", @@ -4491,7 +4460,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.31.0" +version = "1.32.0" dependencies = [ "anstream 0.6.21", "anyhow", @@ -4511,7 +4480,6 @@ dependencies = [ "etcetera 0.11.0", "futures", "goose", - "goose-acp", "goose-mcp", "indicatif", "open", @@ -4544,7 +4512,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.31.0" +version = "1.32.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4573,7 +4541,7 @@ dependencies = [ [[package]] name = "goose-sdk" -version = "1.31.0" +version = "1.32.0" dependencies = [ "agent-client-protocol-schema", "sacp", @@ -4586,7 +4554,7 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.31.0" +version = "1.32.0" dependencies = [ "anyhow", "aws-lc-rs", @@ -4634,7 +4602,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.31.0" +version = "1.32.0" dependencies = [ "clap", "serde_json", @@ -4642,7 +4610,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.31.0" +version = "1.32.0" dependencies = [ "axum", "env-lock", @@ -8295,9 +8263,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -10360,9 +10328,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" [[package]] name = "thiserror" diff --git a/Cargo.toml b/Cargo.toml index 555f40951853..5da5c4a2e3d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,8 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.31.0" +version = "1.32.0" +rust-version = "1.91.1" authors = ["AAIF "] license = "Apache-2.0" repository = "https://github.com/aaif-goose/goose" diff --git a/Justfile b/Justfile index fd9a43e9e446..3e7a22092964 100644 --- a/Justfile +++ b/Justfile @@ -205,7 +205,7 @@ check-acp-schema: generate-acp-types #!/usr/bin/env bash set -e echo "🔍 Checking ACP schema and generated types are up-to-date..." - if ! git diff --exit-code crates/goose-acp/acp-schema.json crates/goose-acp/acp-meta.json ui/sdk/src/generated/; then + if ! git diff --exit-code crates/goose/acp-schema.json crates/goose/acp-meta.json ui/sdk/src/generated/; then echo "" echo "❌ ACP generated files are out of date!" echo "" @@ -217,8 +217,8 @@ check-acp-schema: generate-acp-types # Generate ACP JSON schema from Rust types generate-acp-schema: @echo "Generating ACP schema..." - cd crates/goose-acp && cargo run --bin generate-acp-schema - @echo "ACP schema generated: crates/goose-acp/acp-schema.json, crates/goose-acp/acp-meta.json" + cd crates/goose && cargo run --bin generate-acp-schema + @echo "ACP schema generated: crates/goose/acp-schema.json, crates/goose/acp-meta.json" # Generate ACP TypeScript types from JSON schema (requires generate-acp-schema first) generate-acp-types: generate-acp-schema diff --git a/README.md b/README.md index fed529b2da4f..a150908f8516 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ _your native open source AI agent — desktop app, CLI, and API — for code, wo >Discord CI +

diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 9a10dc6b5773..e031d00c0c4d 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -17,7 +17,7 @@ Make a copy of this document for each version and check off as steps are verifie ### Provider Testing -- [ ] Run `./scripts/test_providers.sh` locally from the release branch and verify all providers/models work +- [ ] Run `cd ui/desktop && pnpm run test:integration:providers` locally from the release branch and verify all providers/models work - [ ] Launch goose, click reset providers, choose databricks and a model ### Starting Conversations diff --git a/crates/goose-acp-macros/Cargo.toml b/crates/goose-acp-macros/Cargo.toml index 43c428e325b1..982f87638d2b 100644 --- a/crates/goose-acp-macros/Cargo.toml +++ b/crates/goose-acp-macros/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "goose-acp-macros" edition.workspace = true +rust-version.workspace = true version.workspace = true authors.workspace = true license.workspace = true diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml deleted file mode 100644 index 8bc2b1e7eed5..000000000000 --- a/crates/goose-acp/Cargo.toml +++ /dev/null @@ -1,64 +0,0 @@ -[package] -name = "goose-acp" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[[bin]] -name = "generate-acp-schema" -path = "src/bin/generate_acp_schema.rs" - -[features] -default = ["code-mode", "rustls-tls"] -code-mode = ["goose/code-mode"] -rustls-tls = ["goose/rustls-tls", "goose-mcp/rustls-tls"] -native-tls = ["goose/native-tls", "goose-mcp/native-tls"] - -[lints] -workspace = true - -[dependencies] -goose = { path = "../goose", default-features = false } -goose-mcp = { path = "../goose-mcp", default-features = false } -rmcp = { workspace = true } -sacp = { workspace = true, features = ["unstable"] } -agent-client-protocol-schema = { workspace = true } -async-trait = { workspace = true } -anyhow = { workspace = true } -tokio = { workspace = true } -tokio-util = { workspace = true, features = ["compat", "rt"] } -tracing = { workspace = true } -serde_json = { workspace = true } -futures = { workspace = true } -regex = { workspace = true } -fs-err = "3" -strum = { workspace = true } -url = { workspace = true } - -# HTTP server dependencies -axum = { workspace = true, features = ["ws"] } -serde = { workspace = true, features = ["derive"] } -tower-http = { workspace = true, features = ["cors"] } -async-stream = { workspace = true } -http-body-util = "0.1.3" -uuid = { workspace = true, features = ["v7"] } -schemars = { workspace = true, features = ["derive"] } -goose-acp-macros = { path = "../goose-acp-macros" } -goose-sdk = { path = "../goose-sdk" } - -[dev-dependencies] -async-trait = { workspace = true } -goose-test-support = { path = "../goose-test-support" } -wiremock = { workspace = true } -tempfile = { workspace = true } -test-case = { workspace = true } -axum = { workspace = true } -rmcp = { workspace = true, features = ["transport-streamable-http-server"] } -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite"] } - -[package.metadata.cargo-machete] -# Used to provide extras imports for sacp -ignored = ["agent-client-protocol-schema"] diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json deleted file mode 100644 index 944d227b663f..000000000000 --- a/crates/goose-acp/acp-meta.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "methods": [ - { - "method": "_goose/extensions/add", - "requestType": "AddExtensionRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/extensions/remove", - "requestType": "RemoveExtensionRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/tools", - "requestType": "GetToolsRequest", - "responseType": "GetToolsResponse" - }, - { - "method": "_goose/resource/read", - "requestType": "ReadResourceRequest", - "responseType": "ReadResourceResponse" - }, - { - "method": "_goose/working_dir/update", - "requestType": "UpdateWorkingDirRequest", - "responseType": "EmptyResponse" - }, - { - "method": "session/delete", - "requestType": "DeleteSessionRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/config/extensions", - "requestType": "GetExtensionsRequest", - "responseType": "GetExtensionsResponse" - }, - { - "method": "_goose/session/extensions", - "requestType": "GetSessionExtensionsRequest", - "responseType": "GetSessionExtensionsResponse" - }, - { - "method": "_goose/providers/list", - "requestType": "ListProvidersRequest", - "responseType": "ListProvidersResponse" - }, - { - "method": "_goose/providers/details", - "requestType": "GetProviderDetailsRequest", - "responseType": "GetProviderDetailsResponse" - }, - { - "method": "_goose/providers/models", - "requestType": "GetProviderModelsRequest", - "responseType": "GetProviderModelsResponse" - }, - { - "method": "_goose/config/read", - "requestType": "ReadConfigRequest", - "responseType": "ReadConfigResponse" - }, - { - "method": "_goose/config/upsert", - "requestType": "UpsertConfigRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/config/remove", - "requestType": "RemoveConfigRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/secret/check", - "requestType": "CheckSecretRequest", - "responseType": "CheckSecretResponse" - }, - { - "method": "_goose/secret/upsert", - "requestType": "UpsertSecretRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/secret/remove", - "requestType": "RemoveSecretRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/session/export", - "requestType": "ExportSessionRequest", - "responseType": "ExportSessionResponse" - }, - { - "method": "_goose/session/import", - "requestType": "ImportSessionRequest", - "responseType": "ImportSessionResponse" - }, - { - "method": "_goose/session/archive", - "requestType": "ArchiveSessionRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/session/unarchive", - "requestType": "UnarchiveSessionRequest", - "responseType": "EmptyResponse" - } - ] -} diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json deleted file mode 100644 index 0f0db1759a37..000000000000 --- a/crates/goose-acp/acp-schema.json +++ /dev/null @@ -1,1004 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "GooseExtensions", - "$defs": { - "AddExtensionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - }, - "config": { - "description": "Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).", - "default": null - } - }, - "required": [ - "sessionId" - ], - "description": "Add an extension to an active session.", - "x-side": "agent", - "x-method": "_goose/extensions/add" - }, - "EmptyResponse": { - "type": "object", - "description": "Empty success response for operations that return no data.", - "x-side": "agent" - }, - "RemoveExtensionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "sessionId", - "name" - ], - "description": "Remove an extension from an active session.", - "x-side": "agent", - "x-method": "_goose/extensions/remove" - }, - "GetToolsRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "List all tools available in a session.", - "x-side": "agent", - "x-method": "_goose/tools" - }, - "GetToolsResponse": { - "type": "object", - "properties": { - "tools": { - "type": "array", - "items": {}, - "description": "Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`." - } - }, - "required": [ - "tools" - ], - "description": "Tools response.", - "x-side": "agent", - "x-method": "_goose/tools" - }, - "ReadResourceRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "extensionName": { - "type": "string" - } - }, - "required": [ - "sessionId", - "uri", - "extensionName" - ], - "description": "Read a resource from an extension.", - "x-side": "agent", - "x-method": "_goose/resource/read" - }, - "ReadResourceResponse": { - "type": "object", - "properties": { - "result": { - "description": "The resource result from the extension (MCP ReadResourceResult).", - "default": null - } - }, - "description": "Resource read response.", - "x-side": "agent", - "x-method": "_goose/resource/read" - }, - "UpdateWorkingDirRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - }, - "workingDir": { - "type": "string" - } - }, - "required": [ - "sessionId", - "workingDir" - ], - "description": "Update the working directory for a session.", - "x-side": "agent", - "x-method": "_goose/working_dir/update" - }, - "DeleteSessionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "Delete a session.", - "x-side": "agent", - "x-method": "session/delete" - }, - "GetExtensionsRequest": { - "type": "object", - "description": "List configured extensions and any warnings.", - "x-side": "agent", - "x-method": "_goose/config/extensions" - }, - "GetExtensionsResponse": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": {}, - "description": "Array of ExtensionEntry objects with `enabled` flag and config details." - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "extensions", - "warnings" - ], - "description": "List configured extensions and any warnings.", - "x-side": "agent", - "x-method": "_goose/config/extensions" - }, - "GetSessionExtensionsRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "x-side": "agent", - "x-method": "_goose/session/extensions" - }, - "GetSessionExtensionsResponse": { - "type": "object", - "properties": { - "extensions": { - "type": "array", - "items": {} - } - }, - "required": [ - "extensions" - ], - "x-side": "agent", - "x-method": "_goose/session/extensions" - }, - "ListProvidersRequest": { - "type": "object", - "description": "List providers available through goose, including the config-default sentinel.", - "x-side": "agent", - "x-method": "_goose/providers/list" - }, - "ListProvidersResponse": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/$defs/ProviderListEntry" - } - } - }, - "required": [ - "providers" - ], - "description": "Provider list response.", - "x-side": "agent", - "x-method": "_goose/providers/list" - }, - "ProviderListEntry": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ] - }, - "GetProviderDetailsRequest": { - "type": "object", - "description": "List providers with full metadata (config keys, setup steps, etc.).", - "x-side": "agent", - "x-method": "_goose/providers/details" - }, - "GetProviderDetailsResponse": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/$defs/ProviderDetailEntry" - } - } - }, - "required": [ - "providers" - ], - "description": "Provider details response.", - "x-side": "agent", - "x-method": "_goose/providers/details" - }, - "ProviderDetailEntry": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "defaultModel": { - "type": "string" - }, - "isConfigured": { - "type": "boolean" - }, - "providerType": { - "type": "string" - }, - "configKeys": { - "type": "array", - "items": { - "$ref": "#/$defs/ProviderConfigKey" - } - }, - "setupSteps": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "knownModels": { - "type": "array", - "items": { - "$ref": "#/$defs/ModelEntry" - }, - "default": [] - } - }, - "required": [ - "name", - "displayName", - "description", - "defaultModel", - "isConfigured", - "providerType", - "configKeys" - ] - }, - "ProviderConfigKey": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "secret": { - "type": "boolean" - }, - "default": { - "type": [ - "string", - "null" - ], - "default": null - }, - "oauthFlow": { - "type": "boolean", - "default": false - }, - "deviceCodeFlow": { - "type": "boolean", - "default": false - }, - "primary": { - "type": "boolean", - "default": false - } - }, - "required": [ - "name", - "required", - "secret" - ] - }, - "ModelEntry": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "contextLimit": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "name", - "contextLimit" - ] - }, - "GetProviderModelsRequest": { - "type": "object", - "properties": { - "providerName": { - "type": "string" - } - }, - "required": [ - "providerName" - ], - "description": "Fetch the full list of models available for a specific provider.", - "x-side": "agent", - "x-method": "_goose/providers/models" - }, - "GetProviderModelsResponse": { - "type": "object", - "properties": { - "models": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "models" - ], - "description": "Provider models response.", - "x-side": "agent", - "x-method": "_goose/providers/models" - }, - "ReadConfigRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Read a single non-secret config value.", - "x-side": "agent", - "x-method": "_goose/config/read" - }, - "ReadConfigResponse": { - "type": "object", - "properties": { - "value": { - "default": null - } - }, - "description": "Config read response.", - "x-side": "agent", - "x-method": "_goose/config/read" - }, - "UpsertConfigRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": {} - }, - "required": [ - "key", - "value" - ], - "description": "Upsert a single non-secret config value.", - "x-side": "agent", - "x-method": "_goose/config/upsert" - }, - "RemoveConfigRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Remove a single non-secret config value.", - "x-side": "agent", - "x-method": "_goose/config/remove" - }, - "CheckSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Check whether a secret exists. Never returns the actual value.", - "x-side": "agent", - "x-method": "_goose/secret/check" - }, - "CheckSecretResponse": { - "type": "object", - "properties": { - "exists": { - "type": "boolean" - } - }, - "required": [ - "exists" - ], - "description": "Secret check response.", - "x-side": "agent", - "x-method": "_goose/secret/check" - }, - "UpsertSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": {} - }, - "required": [ - "key", - "value" - ], - "description": "Set a secret value (write-only).", - "x-side": "agent", - "x-method": "_goose/secret/upsert" - }, - "RemoveSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Remove a secret.", - "x-side": "agent", - "x-method": "_goose/secret/remove" - }, - "ExportSessionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "Export a session as a JSON string.", - "x-side": "agent", - "x-method": "_goose/session/export" - }, - "ExportSessionResponse": { - "type": "object", - "properties": { - "data": { - "type": "string" - } - }, - "required": [ - "data" - ], - "description": "Export session response — raw JSON of the goose session with `conversation`.", - "x-side": "agent", - "x-method": "_goose/session/export" - }, - "ImportSessionRequest": { - "type": "object", - "properties": { - "data": { - "type": "string" - } - }, - "required": [ - "data" - ], - "description": "Import a session from a JSON string.", - "x-side": "agent", - "x-method": "_goose/session/import" - }, - "ImportSessionResponse": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - }, - "messageCount": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "sessionId", - "messageCount" - ], - "description": "Import session response — metadata about the newly created session.", - "x-side": "agent", - "x-method": "_goose/session/import" - }, - "ArchiveSessionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "Archive a session (soft delete).", - "x-side": "agent", - "x-method": "_goose/session/archive" - }, - "UnarchiveSessionRequest": { - "type": "object", - "properties": { - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId" - ], - "description": "Unarchive a previously archived session.", - "x-side": "agent", - "x-method": "_goose/session/unarchive" - }, - "ExtRequest": { - "properties": { - "id": { - "type": "string" - }, - "method": { - "type": "string" - }, - "params": { - "anyOf": [ - { - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/$defs/AddExtensionRequest" - } - ], - "description": "Params for _goose/extensions/add", - "title": "AddExtensionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/RemoveExtensionRequest" - } - ], - "description": "Params for _goose/extensions/remove", - "title": "RemoveExtensionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetToolsRequest" - } - ], - "description": "Params for _goose/tools", - "title": "GetToolsRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ReadResourceRequest" - } - ], - "description": "Params for _goose/resource/read", - "title": "ReadResourceRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/UpdateWorkingDirRequest" - } - ], - "description": "Params for _goose/working_dir/update", - "title": "UpdateWorkingDirRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/DeleteSessionRequest" - } - ], - "description": "Params for session/delete", - "title": "DeleteSessionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetExtensionsRequest" - } - ], - "description": "Params for _goose/config/extensions", - "title": "GetExtensionsRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetSessionExtensionsRequest" - } - ], - "description": "Params for _goose/session/extensions", - "title": "GetSessionExtensionsRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ListProvidersRequest" - } - ], - "description": "Params for _goose/providers/list", - "title": "ListProvidersRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetProviderDetailsRequest" - } - ], - "description": "Params for _goose/providers/details", - "title": "GetProviderDetailsRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetProviderModelsRequest" - } - ], - "description": "Params for _goose/providers/models", - "title": "GetProviderModelsRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ReadConfigRequest" - } - ], - "description": "Params for _goose/config/read", - "title": "ReadConfigRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/UpsertConfigRequest" - } - ], - "description": "Params for _goose/config/upsert", - "title": "UpsertConfigRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/RemoveConfigRequest" - } - ], - "description": "Params for _goose/config/remove", - "title": "RemoveConfigRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/CheckSecretRequest" - } - ], - "description": "Params for _goose/secret/check", - "title": "CheckSecretRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/UpsertSecretRequest" - } - ], - "description": "Params for _goose/secret/upsert", - "title": "UpsertSecretRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/RemoveSecretRequest" - } - ], - "description": "Params for _goose/secret/remove", - "title": "RemoveSecretRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ExportSessionRequest" - } - ], - "description": "Params for _goose/session/export", - "title": "ExportSessionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ImportSessionRequest" - } - ], - "description": "Params for _goose/session/import", - "title": "ImportSessionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ArchiveSessionRequest" - } - ], - "description": "Params for _goose/session/archive", - "title": "ArchiveSessionRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/UnarchiveSessionRequest" - } - ], - "description": "Params for _goose/session/unarchive", - "title": "UnarchiveSessionRequest" - } - ] - }, - { - "description": "Untyped params", - "type": [ - "object", - "null" - ] - } - ] - } - }, - "required": [ - "id", - "method" - ], - "type": "object", - "x-docs-ignore": true - }, - "ExtResponse": { - "anyOf": [ - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "anyOf": [ - { - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/$defs/EmptyResponse" - } - ], - "title": "EmptyResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetToolsResponse" - } - ], - "title": "GetToolsResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ReadResourceResponse" - } - ], - "title": "ReadResourceResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetExtensionsResponse" - } - ], - "title": "GetExtensionsResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetSessionExtensionsResponse" - } - ], - "title": "GetSessionExtensionsResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ListProvidersResponse" - } - ], - "title": "ListProvidersResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetProviderDetailsResponse" - } - ], - "title": "GetProviderDetailsResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/GetProviderModelsResponse" - } - ], - "title": "GetProviderModelsResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ReadConfigResponse" - } - ], - "title": "ReadConfigResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/CheckSecretResponse" - } - ], - "title": "CheckSecretResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ExportSessionResponse" - } - ], - "title": "ExportSessionResponse" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ImportSessionResponse" - } - ], - "title": "ImportSessionResponse" - } - ] - }, - { - "description": "Untyped result" - } - ] - } - }, - "required": [ - "id" - ], - "title": "Success", - "type": "object" - }, - { - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": {} - }, - "required": [ - "code", - "message" - ] - }, - "id": { - "type": "string" - } - }, - "required": [ - "id", - "error" - ], - "title": "Error", - "type": "object" - } - ], - "x-docs-ignore": true - } - }, - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/$defs/ExtRequest" - } - ], - "description": "Extension request (client → agent)", - "title": "Request" - }, - { - "allOf": [ - { - "$ref": "#/$defs/ExtResponse" - } - ], - "description": "Extension response (agent → client)", - "title": "Response" - } - ] -} diff --git a/crates/goose-acp/src/lib.rs b/crates/goose-acp/src/lib.rs deleted file mode 100644 index d1bddef8c572..000000000000 --- a/crates/goose-acp/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -#![recursion_limit = "256"] - -mod adapters; -pub use goose_sdk::custom_requests; -mod fs; -pub mod server; -pub mod server_factory; -pub(crate) mod tools; -pub mod transport; diff --git a/crates/goose-acp/src/transport/http.rs b/crates/goose-acp/src/transport/http.rs deleted file mode 100644 index 608009e45930..000000000000 --- a/crates/goose-acp/src/transport/http.rs +++ /dev/null @@ -1,322 +0,0 @@ -use anyhow::Result; -use axum::{ - body::Body, - extract::State, - http::{Request, StatusCode}, - response::{IntoResponse, Response, Sse}, -}; -use http_body_util::BodyExt; -use serde_json::Value; -use std::{collections::HashMap, convert::Infallible, sync::Arc, time::Duration}; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use tracing::{error, info}; - -use super::*; -use crate::adapters::{ReceiverToAsyncRead, SenderToAsyncWrite}; -use crate::server_factory::AcpServer; - -pub(crate) struct HttpState { - server: Arc, - // Keyed by acp_session_id: a connection-scoped UUID serving many Goose sessions. - sessions: RwLock>, -} - -impl HttpState { - pub fn new(server: Arc) -> Self { - Self { - server, - sessions: RwLock::new(HashMap::new()), - } - } - - async fn create_session(&self) -> Result { - let (to_agent_tx, to_agent_rx) = mpsc::channel::(256); - let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::(); - - let agent = self.server.create_agent().await.map_err(|e| { - error!("Failed to create agent: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let acp_session_id = uuid::Uuid::new_v4().to_string(); - - let read_stream = ReceiverToAsyncRead::new(to_agent_rx); - let write_stream = SenderToAsyncWrite::new(from_agent_tx); - let fut = crate::server::serve(agent, read_stream.compat(), write_stream.compat_write()); - let handle = tokio::spawn(async move { - if let Err(e) = fut.await { - error!("ACP session error: {}", e); - } - }); - - self.sessions.write().await.insert( - acp_session_id.clone(), - TransportSession { - to_agent_tx, - from_agent_rx: Arc::new(Mutex::new(from_agent_rx)), - handle, - }, - ); - - info!(acp_session_id = %acp_session_id, "Session created"); - Ok(acp_session_id) - } - - async fn has_session(&self, acp_session_id: &str) -> bool { - self.sessions.read().await.contains_key(acp_session_id) - } - - async fn remove_session(&self, acp_session_id: &str) { - if let Some(session) = self.sessions.write().await.remove(acp_session_id) { - session.handle.abort(); - info!(acp_session_id = %acp_session_id, "Session removed"); - } - } - - async fn send_message(&self, acp_session_id: &str, message: String) -> Result<(), StatusCode> { - let sessions = self.sessions.read().await; - let session = sessions.get(acp_session_id).ok_or(StatusCode::NOT_FOUND)?; - session - .to_agent_tx - .send(message) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) - } - - async fn get_receiver( - &self, - acp_session_id: &str, - ) -> Result>>, StatusCode> { - let sessions = self.sessions.read().await; - let session = sessions.get(acp_session_id).ok_or(StatusCode::NOT_FOUND)?; - Ok(session.from_agent_rx.clone()) - } -} - -fn create_sse_stream( - receiver: Arc>>, - cleanup: Option<(Arc, String)>, -) -> Sse>> { - let stream = async_stream::stream! { - let mut rx = receiver.lock().await; - while let Some(msg) = rx.recv().await { - yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg)); - } - if let Some((state, acp_session_id)) = cleanup { - state.remove_session(&acp_session_id).await; - } - }; - - Sse::new(stream).keep_alive( - axum::response::sse::KeepAlive::new() - .interval(Duration::from_secs(15)) - .text(""), - ) -} - -async fn handle_initialize(state: Arc, json_message: &Value) -> Response { - let acp_session_id = match state.create_session().await { - Ok(id) => id, - Err(status) => return status.into_response(), - }; - - let message_str = serde_json::to_string(json_message).unwrap(); - if let Err(status) = state.send_message(&acp_session_id, message_str).await { - state.remove_session(&acp_session_id).await; - return status.into_response(); - } - - let receiver = match state.get_receiver(&acp_session_id).await { - Ok(r) => r, - Err(status) => { - state.remove_session(&acp_session_id).await; - return status.into_response(); - } - }; - - let sse = create_sse_stream(receiver, Some((state.clone(), acp_session_id.clone()))); - let mut response = sse.into_response(); - response - .headers_mut() - .insert(HEADER_SESSION_ID, acp_session_id.parse().unwrap()); - response -} - -async fn handle_request( - state: Arc, - acp_session_id: String, - json_message: &Value, -) -> Response { - if !state.has_session(&acp_session_id).await { - return (StatusCode::NOT_FOUND, "Session not found").into_response(); - } - - let message_str = serde_json::to_string(json_message).unwrap(); - if let Err(status) = state.send_message(&acp_session_id, message_str).await { - return status.into_response(); - } - - let receiver = match state.get_receiver(&acp_session_id).await { - Ok(r) => r, - Err(status) => return status.into_response(), - }; - - create_sse_stream(receiver, None).into_response() -} - -async fn handle_notification_or_response( - state: Arc, - acp_session_id: String, - json_message: &Value, -) -> Response { - if !state.has_session(&acp_session_id).await { - return (StatusCode::NOT_FOUND, "Session not found").into_response(); - } - - let message_str = serde_json::to_string(json_message).unwrap(); - if let Err(status) = state.send_message(&acp_session_id, message_str).await { - return status.into_response(); - } - - StatusCode::ACCEPTED.into_response() -} - -pub(crate) async fn handle_post( - State(state): State>, - request: Request, -) -> Response { - if !accepts_json_and_sse(&request) { - return ( - StatusCode::NOT_ACCEPTABLE, - "Not Acceptable: Client must accept both application/json and text/event-stream", - ) - .into_response(); - } - - if !content_type_is_json(&request) { - return ( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "Unsupported Media Type: Content-Type must be application/json", - ) - .into_response(); - } - - let acp_session_id = get_session_id(&request); - - let body_bytes = match request.into_body().collect().await { - Ok(collected) => collected.to_bytes(), - Err(e) => { - error!("Failed to read request body: {}", e); - return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response(); - } - }; - - let json_message: Value = match serde_json::from_slice(&body_bytes) { - Ok(v) => v, - Err(e) => { - error!("Failed to parse JSON: {}", e); - return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(); - } - }; - - if json_message.is_array() { - return ( - StatusCode::NOT_IMPLEMENTED, - "Batch requests are not supported", - ) - .into_response(); - } - - if is_initialize_request(&json_message) { - handle_initialize(state.clone(), &json_message).await - } else if is_jsonrpc_request(&json_message) { - let Some(id) = acp_session_id else { - return ( - StatusCode::BAD_REQUEST, - "Bad Request: Acp-Session-Id header required", - ) - .into_response(); - }; - handle_request(state.clone(), id, &json_message).await - } else if is_jsonrpc_notification(&json_message) || is_jsonrpc_response(&json_message) { - let Some(id) = acp_session_id else { - return ( - StatusCode::BAD_REQUEST, - "Bad Request: Acp-Session-Id header required", - ) - .into_response(); - }; - handle_notification_or_response(state.clone(), id, &json_message).await - } else { - (StatusCode::BAD_REQUEST, "Invalid JSON-RPC message").into_response() - } -} - -pub(crate) async fn handle_get(state: Arc, request: Request) -> Response { - if !accepts_mime_type(&request, EVENT_STREAM_MIME_TYPE) { - return ( - StatusCode::NOT_ACCEPTABLE, - "Not Acceptable: Client must accept text/event-stream", - ) - .into_response(); - } - - let acp_session_id = match get_session_id(&request) { - Some(id) => id, - None => { - return ( - StatusCode::BAD_REQUEST, - "Bad Request: Acp-Session-Id header required", - ) - .into_response(); - } - }; - - if !state.has_session(&acp_session_id).await { - return (StatusCode::NOT_FOUND, "Session not found").into_response(); - } - - let receiver = match state.get_receiver(&acp_session_id).await { - Ok(r) => r, - Err(status) => return status.into_response(), - }; - - let stream = async_stream::stream! { - let mut rx = receiver.lock().await; - while let Some(msg) = rx.recv().await { - yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg)); - } - }; - - Sse::new(stream) - .keep_alive( - axum::response::sse::KeepAlive::new() - .interval(Duration::from_secs(15)) - .text(""), - ) - .into_response() -} - -pub(crate) async fn handle_delete( - State(state): State>, - request: Request, -) -> Response { - let acp_session_id = match get_session_id(&request) { - Some(id) => id, - None => { - return ( - StatusCode::BAD_REQUEST, - "Bad Request: Acp-Session-Id header required", - ) - .into_response(); - } - }; - - if !state.has_session(&acp_session_id).await { - return (StatusCode::NOT_FOUND, "Session not found").into_response(); - } - - state.remove_session(&acp_session_id).await; - StatusCode::ACCEPTED.into_response() -} diff --git a/crates/goose-acp/src/transport/websocket.rs b/crates/goose-acp/src/transport/websocket.rs deleted file mode 100644 index 65a84737adbf..000000000000 --- a/crates/goose-acp/src/transport/websocket.rs +++ /dev/null @@ -1,158 +0,0 @@ -use anyhow::Result; -use axum::{ - extract::ws::{Message, WebSocket, WebSocketUpgrade}, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use futures::{SinkExt, StreamExt}; -use std::{collections::HashMap, sync::Arc}; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use tracing::{debug, error, info, warn}; - -use super::{TransportSession, HEADER_SESSION_ID}; -use crate::adapters::{ReceiverToAsyncRead, SenderToAsyncWrite}; -use crate::server_factory::AcpServer; - -pub(crate) struct WsState { - server: Arc, - // Keyed by acp_session_id: a connection-scoped UUID serving many Goose sessions. - sessions: RwLock>, -} - -impl WsState { - pub fn new(server: Arc) -> Self { - Self { - server, - sessions: RwLock::new(HashMap::new()), - } - } - - async fn create_connection(&self) -> Result { - let (to_agent_tx, to_agent_rx) = mpsc::channel::(256); - let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::(); - - let agent = self.server.create_agent().await?; - - let acp_session_id = uuid::Uuid::new_v4().to_string(); - - let read_stream = ReceiverToAsyncRead::new(to_agent_rx); - let write_stream = SenderToAsyncWrite::new(from_agent_tx); - let fut = crate::server::serve(agent, read_stream.compat(), write_stream.compat_write()); - let handle = tokio::spawn(async move { - if let Err(e) = fut.await { - error!("ACP WebSocket session error: {}", e); - } - }); - - self.sessions.write().await.insert( - acp_session_id.clone(), - TransportSession { - to_agent_tx, - from_agent_rx: Arc::new(Mutex::new(from_agent_rx)), - handle, - }, - ); - - info!(acp_session_id = %acp_session_id, "WebSocket connection created"); - Ok(acp_session_id) - } - - async fn remove_connection(&self, acp_session_id: &str) { - if let Some(session) = self.sessions.write().await.remove(acp_session_id) { - session.handle.abort(); - info!(acp_session_id = %acp_session_id, "WebSocket connection removed"); - } - } -} - -pub(crate) async fn handle_get(state: Arc, ws: WebSocketUpgrade) -> Response { - let acp_session_id = match state.create_connection().await { - Ok(id) => id, - Err(e) => { - error!("Failed to create WebSocket connection: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to create WebSocket connection", - ) - .into_response(); - } - }; - - let mut response = ws.on_upgrade({ - let acp_session_id = acp_session_id.clone(); - move |socket| handle_ws(socket, state, acp_session_id) - }); - response - .headers_mut() - .insert(HEADER_SESSION_ID, acp_session_id.parse().unwrap()); - response -} - -pub(crate) async fn handle_ws(socket: WebSocket, state: Arc, acp_session_id: String) { - let (mut ws_tx, mut ws_rx) = socket.split(); - - let (to_agent, from_agent) = { - let sessions = state.sessions.read().await; - match sessions.get(&acp_session_id) { - Some(session) => (session.to_agent_tx.clone(), session.from_agent_rx.clone()), - None => { - error!(acp_session_id = %acp_session_id, "Session not found after creation"); - return; - } - } - }; - - debug!(acp_session_id = %acp_session_id, "Starting bidirectional message loop"); - - let mut from_agent_rx = from_agent.lock().await; - - loop { - tokio::select! { - Some(msg_result) = ws_rx.next() => { - match msg_result { - Ok(Message::Text(text)) => { - let text_str = text.to_string(); - debug!(acp_session_id = %acp_session_id, "Client → Agent: {} bytes", text_str.len()); - if let Err(e) = to_agent.send(text_str).await { - error!(acp_session_id = %acp_session_id, "Failed to send to agent: {}", e); - break; - } - } - Ok(Message::Close(frame)) => { - debug!(acp_session_id = %acp_session_id, "Client closed connection: {:?}", frame); - break; - } - Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => { - // Axum handles ping/pong automatically - continue; - } - Ok(Message::Binary(_)) => { - warn!(acp_session_id = %acp_session_id, "Ignoring binary message (ACP uses text)"); - continue; - } - Err(e) => { - error!(acp_session_id = %acp_session_id, "WebSocket error: {}", e); - break; - } - } - } - - Some(text) = from_agent_rx.recv() => { - debug!(acp_session_id = %acp_session_id, "Agent → Client: {} bytes", text.len()); - if let Err(e) = ws_tx.send(Message::Text(text.into())).await { - error!(acp_session_id = %acp_session_id, "Failed to send to client: {}", e); - break; - } - } - - else => { - debug!(acp_session_id = %acp_session_id, "Both channels closed"); - break; - } - } - } - - debug!(acp_session_id = %acp_session_id, "Cleaning up connection"); - state.remove_connection(&acp_session_id).await; -} diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 6c20a644912a..7c801cdf6f1e 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "goose-cli" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -21,7 +22,6 @@ path = "src/bin/generate_manpages.rs" [dependencies] clap_mangen = "0.2.31" goose = { path = "../goose", default-features = false } -goose-acp = { path = "../goose-acp", default-features = false } goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } clap = { workspace = true } @@ -70,7 +70,7 @@ winapi = { workspace = true } [features] default = ["code-mode", "local-inference", "aws-providers", "telemetry", "otel", "rustls-tls"] -code-mode = ["goose/code-mode", "goose-acp/code-mode"] +code-mode = ["goose/code-mode"] local-inference = ["goose/local-inference"] aws-providers = ["goose/aws-providers"] cuda = ["goose/cuda", "local-inference"] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5999e319612b..ecf7f81d766d 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -709,6 +709,8 @@ enum Command { /// Show verbose information including current configuration #[arg(short, long, help = "Show verbose information including config.yaml")] verbose: bool, + #[arg(long, help = "Test provider connection and show status")] + check: bool, }, #[command(about = "Check that your Goose setup is working")] @@ -1064,8 +1066,9 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { } async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> Result<()> { + use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; + use goose::acp::transport::create_router; use goose::config::paths::Paths; - use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig}; use std::net::SocketAddr; use std::sync::Arc; use tracing::info; @@ -1081,7 +1084,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), })); - let router = goose_acp::transport::create_router(server); + let router = create_router(server); let addr: SocketAddr = format!("{}:{}", host, port).parse()?; info!("Starting ACP server on {}", addr); @@ -1764,9 +1767,9 @@ pub async fn cli() -> anyhow::Result<()> { } Some(Command::Configure {}) => handle_configure().await, Some(Command::Doctor {}) => crate::commands::doctor::handle_doctor().await, - Some(Command::Info { verbose }) => handle_info(verbose), + Some(Command::Info { verbose, check }) => handle_info(verbose, check).await, Some(Command::Mcp { server }) => handle_mcp_command(server).await, - Some(Command::Acp { builtins }) => goose_acp::server::run(builtins).await, + Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await, Some(Command::Serve { host, port, diff --git a/crates/goose-cli/src/commands/info.rs b/crates/goose-cli/src/commands/info.rs index 56c9d295b083..d702b3e43eb9 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -1,9 +1,12 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use console::style; use goose::config::paths::Paths; use goose::config::Config; +use goose::conversation::message::Message; +use goose::providers::errors::ProviderError; use goose::session::session_manager::{DB_NAME, SESSIONS_FOLDER}; use serde_yaml; +use std::time::Duration; fn print_aligned(label: &str, value: &str, width: usize) { println!(" {: String { } } -pub fn handle_info(verbose: bool) -> Result<()> { +struct ProviderCheckSuccess { + provider: String, + model: String, + elapsed: Duration, +} + +enum ProviderCheckError { + NotConfigured { + label: &'static str, + error: String, + }, + InvalidModel(String), + ProviderCreate { + error: String, + show_api_key_hint: bool, + }, + ProviderRequest(ProviderError), +} + +async fn check_provider( + config: &Config, +) -> std::result::Result { + let (provider, model) = match (config.get_goose_provider(), config.get_goose_model()) { + (Ok(provider), Ok(model)) => (provider, model), + (Err(e), _) => { + return Err(ProviderCheckError::NotConfigured { + label: "Provider:", + error: e.to_string(), + }); + } + (_, Err(e)) => { + return Err(ProviderCheckError::NotConfigured { + label: "Model:", + error: e.to_string(), + }); + } + }; + + let model_config = goose::model::ModelConfig::new(&model) + .map_err(|e| ProviderCheckError::InvalidModel(e.to_string()))? + .with_canonical_limits(&provider); + + let provider_client = goose::providers::create(&provider, model_config, Vec::new()) + .await + .map_err(|e| { + let error = e.to_string(); + ProviderCheckError::ProviderCreate { + show_api_key_hint: error.contains("not found") || error.contains("API_KEY"), + error, + } + })?; + + let test_msg = Message::user().with_text("Say 'ok'"); + let model_config = provider_client.get_model_config(); + let start = std::time::Instant::now(); + provider_client + .complete(&model_config, "check", "", &[test_msg], &[]) + .await + .map_err(ProviderCheckError::ProviderRequest)?; + + Ok(ProviderCheckSuccess { + provider, + model, + elapsed: start.elapsed(), + }) +} + +pub async fn handle_info(verbose: bool, check: bool) -> Result<()> { let logs_dir = Paths::in_state_dir("logs"); let sessions_dir = Paths::in_data_dir(SESSIONS_FOLDER); let sessions_db = sessions_dir.join(DB_NAME); @@ -90,5 +160,115 @@ pub fn handle_info(verbose: bool) -> Result<()> { } } + if check { + println!("\n{}", style("Provider Check:").cyan().bold()); + + let result = check_provider(config).await; + match &result { + Ok(success) => { + print_aligned("Provider:", &success.provider, label_padding); + print_aligned("Model:", &success.model, label_padding); + print_aligned("Auth:", &style("ok").green().to_string(), label_padding); + print_aligned( + "Connection:", + &format!( + "{} (verified in {:.1}s)", + style("ok").green(), + success.elapsed.as_secs_f64() + ), + label_padding, + ); + } + Err(ProviderCheckError::NotConfigured { label, error }) => { + print_aligned( + label, + &format!("{} {}", style("not configured:").red(), error), + label_padding, + ); + print_aligned( + "Hint:", + &format!("Run '{}'", style("goose configure").cyan()), + label_padding, + ); + } + Err(ProviderCheckError::InvalidModel(error)) => { + print_aligned( + "Model:", + &format!("{} {}", style("invalid:").red(), error), + label_padding, + ); + } + Err(ProviderCheckError::ProviderCreate { + error, + show_api_key_hint, + }) => { + // Split auth failures (missing/invalid credential) from provider + // construction failures (unknown provider, malformed provider + // config). Labeling the latter as "Auth: FAILED" misdirects + // troubleshooting toward rotating API keys. + if *show_api_key_hint { + print_aligned( + "Auth:", + &format!("{} {}", style("FAILED").red().bold(), error), + label_padding, + ); + print_aligned( + "Hint:", + &format!( + "Set the API key in your environment or run '{}'", + style("goose configure").cyan() + ), + label_padding, + ); + } else { + print_aligned( + "Provider:", + &format!("{} {}", style("FAILED").red().bold(), error), + label_padding, + ); + print_aligned( + "Hint:", + &format!( + "Check the provider name and config, or run '{}'", + style("goose configure").cyan() + ), + label_padding, + ); + } + } + Err(ProviderCheckError::ProviderRequest(error)) => match error { + ProviderError::Authentication(_) => { + print_aligned( + "Auth:", + &format!("{} {}", style("FAILED").red().bold(), error), + label_padding, + ); + print_aligned( + "Hint:", + &format!( + "Check your API key or run '{}'", + style("goose configure").cyan() + ), + label_padding, + ); + } + _ => { + print_aligned( + "Check:", + &format!("{} {}", style("FAILED").red().bold(), error), + label_padding, + ); + } + }, + } + + // Propagate non-zero exit status so automation (CI scripts, install + // checks, health probes) can rely on `goose info --check` as a + // pre-flight verifier. + if result.is_err() { + return Err(anyhow!("provider check failed")); + } + } + Ok(()) } diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index eae04307b366..ade183645f6c 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -232,6 +232,7 @@ where Arc::new(mock_client), None, None, + None, ) .await; diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index e2edbfb12479..f9c3d2bf6795 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -121,6 +121,31 @@ impl GooseCompleter { Ok((line.len(), vec![])) } + /// Complete skill names for the /skills command + fn complete_skill_names(&self, line: &str) -> Result<(usize, Vec)> { + use goose::skills::list_installed_skills; + + let cwd = std::env::current_dir().unwrap_or_default(); + let skills = list_installed_skills(Some(&cwd)); + let skill_names: Vec = skills.iter().map(|s| s.name.clone()).collect(); + + // Complete the last letter being typed (e.g. "/skills coding in") + let last = line.rsplit_once(' ').map_or("", |(_, w)| w); + let pos = line.len() - last.len(); + + let partial = last.to_lowercase(); + let candidates: Vec = skill_names + .iter() + .filter(|name| name.to_lowercase().starts_with(&partial)) + .map(|name| Pair { + display: name.clone(), + replacement: format!("{} ", name), + }) + .collect(); + + Ok((pos, candidates)) + } + /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { // Define available slash commands @@ -136,6 +161,7 @@ impl GooseCompleter { "/prompt", "/mode", "/recipe", + "/skills", ]; // Find commands that match the prefix @@ -374,6 +400,10 @@ impl Completer for GooseCompleter { return self.complete_mode_flags(line); } + if line.starts_with("/skills ") { + return self.complete_skill_names(line); + } + return Ok((pos, vec![])); } diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 8bc067ddb109..2641a77b725e 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -27,6 +27,8 @@ pub enum InputResult { Compact, ToggleFullToolOutput, Edit(Option), + ListSkills, + LoadSkills(Vec), } #[derive(Debug)] @@ -204,6 +206,7 @@ fn handle_slash_command(input: &str) -> Option { const CMD_SUMMARIZE_DEPRECATED: &str = "/summarize"; const CMD_EDIT: &str = "/edit"; const CMD_EDIT_WITH_SPACE: &str = "/edit "; + const CMD_SKILLS: &str = "/skills"; match input { "/exit" | "/quit" => Some(InputResult::Exit), @@ -267,6 +270,16 @@ fn handle_slash_command(input: &str) -> Option { s if s == CMD_CLEAR => Some(InputResult::Clear), s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s), s if s == CMD_COMPACT => Some(InputResult::Compact), + // Match "/skills" exactly or "/skills " with args - avoids matching e.g. "/skillsextra" + s if s == CMD_SKILLS || s.starts_with(&format!("{CMD_SKILLS} ")) => { + let args = s.get(CMD_SKILLS.len()..).unwrap_or("").trim(); + if args.is_empty() { + Some(InputResult::ListSkills) + } else { + let names: Vec = args.split_whitespace().map(String::from).collect(); + Some(InputResult::LoadSkills(names)) + } + } s if s == CMD_SUMMARIZE_DEPRECATED => { println!("{}", console::style("⚠️ Note: /summarize has been renamed to /compact and will be removed in a future release.").yellow()); Some(InputResult::Compact) @@ -417,6 +430,7 @@ fn print_help() { /compact - Compact the current conversation to reduce context length while preserving key information. /edit [text] - Open your prompt editor to compose a message. Optionally pre-fill with text. Uses $GOOSE_PROMPT_EDITOR, $VISUAL, or $EDITOR (in that order). +/skills - List available skills or enable skills by name (usage: /skills [...]) /? or /help - Display this help message /clear - Clears the current chat history @@ -747,4 +761,48 @@ mod tests { // Test /editfoo is not a valid command assert!(handle_slash_command("/editfoo").is_none()); } + + #[test] + fn test_skill_command() { + // Test with a single skill name + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills coding") else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills coding") + ); + }; + assert_eq!(names, vec!["coding"]); + + // Test with multiple skill names + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills coding insight") + else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills coding insight") + ); + }; + assert_eq!(names, vec!["coding", "insight"]); + + // Test with extra whitespace + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills my-skill ") + else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills my-skill ") + ); + }; + assert_eq!(names, vec!["my-skill"]); + + // Test with no name: ListSkills + assert!(matches!( + handle_slash_command("/skills"), + Some(InputResult::ListSkills) + )); + + // Test with only whitespace after /skills: ListSkills + assert!(matches!( + handle_slash_command("/skills "), + Some(InputResult::ListSkills) + )); + } } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 535bc54b7ac6..4deb36ae3d14 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -666,6 +666,14 @@ impl CliSession { } } } + InputResult::LoadSkills(names) => { + history.save(editor); + self.handle_load_skills(&names).await?; + } + InputResult::ListSkills => { + history.save(editor); + self.handle_list_skills().await?; + } } Ok(()) } @@ -875,6 +883,55 @@ impl CliSession { } } + async fn handle_load_skills(&mut self, names: &[String]) -> Result<()> { + // NOTE: We don't validate the skill names here because the load_skill tool will + // handle that and provide feedback to the user if any skill names are invalid. + let message = format!( + "Use the load_skill tool to load the following skills: {}.", + names + .iter() + .map(|n| format!("\"{}\"", n)) + .collect::>() + .join(", ") + ); + self.push_message(Message::user().with_text(&message)); + output::show_thinking(); + let result = self + .process_agent_response(true, CancellationToken::default()) + .await; + output::hide_thinking(); + result?; + + Ok(()) + } + + async fn handle_list_skills(&mut self) -> Result<()> { + use comfy_table::{presets, Cell, ContentArrangement, Table}; + use goose::skills::list_installed_skills; + let cwd = std::env::current_dir().unwrap_or_default(); + let skills = list_installed_skills(Some(&cwd)); + + if skills.is_empty() { + println!("{}", console::style("No skills available.").yellow()); + return Ok(()); + } + + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.load_preset(presets::ASCII_FULL); + table.set_header(vec!["Skill", "Description"]); + + let mut sorted_skills = skills; + sorted_skills.sort_by(|a, b| a.name.cmp(&b.name)); + + for skill in &sorted_skills { + table.add_row(vec![Cell::new(&skill.name), Cell::new(&skill.description)]); + } + + println!("{table}"); + Ok(()) + } + async fn handle_compact(&mut self) -> Result<()> { let prompt = "Are you sure you want to compact this conversation? This will condense the message history."; let should_summarize = match cliclack::confirm(prompt).initial_value(true).interact() { diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index f1e5dfc9287b..755aab468120 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -2,6 +2,7 @@ name = "goose-mcp" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/goose-sdk/Cargo.toml b/crates/goose-sdk/Cargo.toml index 6eef747a3c4d..18fa86dafe56 100644 --- a/crates/goose-sdk/Cargo.toml +++ b/crates/goose-sdk/Cargo.toml @@ -2,6 +2,7 @@ name = "goose-sdk" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index bbc375be09f3..c8fc781ce78b 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -1,6 +1,7 @@ use sacp::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. @@ -98,11 +99,43 @@ pub struct GetExtensionsRequest {} /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct GetExtensionsResponse { - /// Array of ExtensionEntry objects with `enabled` flag and config details. + /// Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details. pub extensions: Vec, pub warnings: Vec, } +/// Persist a new extension to the user's global goose config. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/config/extensions/add", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct AddConfigExtensionRequest { + pub name: String, + /// Extension configuration. Must be a JSON object matching one of the + /// `ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`). + /// `name` and `enabled` are injected server-side. + #[serde(default)] + pub extension_config: serde_json::Value, + #[serde(default)] + pub enabled: bool, +} + +/// Remove a persisted extension from the user's global goose config. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/config/extensions/remove", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct RemoveConfigExtensionRequest { + pub config_key: String, +} + +/// Toggle the `enabled` flag for a persisted extension in the user's global goose config. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/config/extensions/toggle", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct ToggleConfigExtensionRequest { + pub config_key: String, + pub enabled: bool, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/session/extensions", response = GetSessionExtensionsResponse)] #[serde(rename_all = "camelCase")] @@ -180,22 +213,22 @@ pub struct RemoveSecretRequest { pub key: String, } -/// List providers available through goose, including the config-default sentinel. +/// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/providers/list", response = ListProvidersResponse)] -pub struct ListProvidersRequest {} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[request(method = "_goose/session/update_project", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct ProviderListEntry { - pub id: String, - pub label: String, +pub struct UpdateSessionProjectRequest { + pub session_id: String, + pub project_id: Option, } -/// Provider list response. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct ListProvidersResponse { - pub providers: Vec, +/// Rename a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/session/rename", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct RenameSessionRequest { + pub session_id: String, + pub title: String, } /// Archive a session (soft delete). @@ -245,70 +278,459 @@ pub struct ImportSessionResponse { pub message_count: u64, } -/// List providers with full metadata (config keys, setup steps, etc.). +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConfigKey { + pub name: String, + pub required: bool, + pub secret: bool, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub oauth_flow: bool, + #[serde(default)] + pub device_code_flow: bool, + #[serde(default)] + pub primary: bool, +} + +/// The type of source entity. +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "camelCase")] +pub enum SourceType { + #[default] + Skill, + BuiltinSkill, + Recipe, + Subrecipe, + Agent, +} + +impl std::fmt::Display for SourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SourceType::Skill => write!(f, "skill"), + SourceType::BuiltinSkill => write!(f, "builtin skill"), + SourceType::Recipe => write!(f, "recipe"), + SourceType::Subrecipe => write!(f, "subrecipe"), + SourceType::Agent => write!(f, "agent"), + } + } +} + +/// A source discovered by Goose and backed by an on-disk path. Sources may be +/// either `global` (shared across all projects) or project-specific. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SourceEntry { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + /// Absolute path to the source on disk. A directory for skills, a file for + /// recipes and agents. + pub directory: String, + /// True when the source lives in the user's global sources directory; false + /// when it lives inside a specific project. + pub global: bool, + /// Paths (absolute) of additional files that live alongside the source. + /// Only skills currently populate this; empty for other source types. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supporting_files: Vec, +} + +impl SourceEntry { + /// Render this source as a markdown block suitable for injecting into an + /// LLM context. Used by the skills and summon runtimes when loading a + /// source into the current conversation. + pub fn to_load_text(&self) -> String { + format!( + "## {} ({})\n\n{}\n\n### Content\n\n{}", + self.name, self.source_type, self.description, self.content + ) + } +} + +/// Create a new source in an explicit target scope (global or project-scoped). #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/providers/details", response = GetProviderDetailsResponse)] -pub struct GetProviderDetailsRequest {} +#[request(method = "_goose/sources/create", response = CreateSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct CreateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + pub global: bool, + /// Absolute path to the project root. Required when `global` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} -/// Provider details response. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetProviderDetailsResponse { - pub providers: Vec, +#[serde(rename_all = "camelCase")] +pub struct CreateSourceResponse { + pub source: SourceEntry, } -/// Fetch the full list of models available for a specific provider. +/// List discovered sources. +/// +/// Today this endpoint only returns skills. If `type` is omitted, it defaults +/// to listing skill sources. Both global and project-scoped skills are included +/// when `project_dir` is set. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/providers/models", response = GetProviderModelsResponse)] +#[request(method = "_goose/sources/list", response = ListSourcesResponse)] #[serde(rename_all = "camelCase")] -pub struct GetProviderModelsRequest { - pub provider_name: String, +pub struct ListSourcesRequest { + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, } -/// Provider models response. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetProviderModelsResponse { - pub models: Vec, +#[serde(rename_all = "camelCase")] +pub struct ListSourcesResponse { + pub sources: Vec, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +/// Update an existing source's name, description, and content by absolute path. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/update", response = UpdateSourceResponse)] #[serde(rename_all = "camelCase")] -pub struct ProviderDetailEntry { +pub struct UpdateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub path: String, pub name: String, - pub display_name: String, pub description: String, - pub default_model: String, - pub is_configured: bool, - pub provider_type: String, - pub config_keys: Vec, + pub content: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSourceResponse { + pub source: SourceEntry, +} + +/// Delete a source and its on-disk directory by absolute path. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub path: String, +} + +/// Export a source at an absolute path as a portable JSON payload. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/export", response = ExportSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceResponse { + pub json: String, + pub filename: String, +} + +/// Import a source from a JSON export payload produced by `_goose/sources/export`. +/// The imported source is written into the explicit target scope; on name +/// collisions a `-imported` suffix is appended. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/import", response = ImportSourcesResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesRequest { + pub data: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesResponse { + pub sources: Vec, +} + +/// Transcribe audio via a dictation provider. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/transcribe", response = DictationTranscribeResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationTranscribeRequest { + /// Base64-encoded audio data + pub audio: String, + /// MIME type (e.g. "audio/wav", "audio/webm") + pub mime_type: String, + /// Provider to use: "openai", "groq", "elevenlabs", or "local" + pub provider: String, +} + +/// Transcription result. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DictationTranscribeResponse { + pub text: String, +} + +/// Get the configuration status of all dictation providers. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/config", response = DictationConfigResponse)] +pub struct DictationConfigRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DictationModelOption { + pub id: String, + pub label: String, + pub description: String, +} + +/// Per-provider configuration status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationProviderStatusEntry { + pub configured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + pub description: String, + pub uses_provider_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_config_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_model: Option, #[serde(default)] - pub setup_steps: Vec, + pub available_models: Vec, +} + +/// Dictation config response — map of provider name to status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DictationConfigResponse { + pub providers: HashMap, +} + +/// List providers with setup metadata and the current model inventory snapshot. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/providers/list", response = ListProvidersResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListProvidersRequest { + /// Only return entries for these providers. Empty means all. #[serde(default)] - pub known_models: Vec, + pub provider_ids: Vec, +} + +/// Provider list response. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListProvidersResponse { + pub entries: Vec, +} + +/// Trigger a background refresh of provider inventories. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/providers/inventory/refresh", + response = RefreshProviderInventoryResponse +)] +#[serde(rename_all = "camelCase")] +pub struct RefreshProviderInventoryRequest { + /// Which providers to refresh. Empty means all known providers. + #[serde(default)] + pub provider_ids: Vec, +} + +/// Refresh acknowledgement. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct RefreshProviderInventoryResponse { + /// Which providers will be refreshed. + pub started: Vec, + /// Which providers were skipped and why. + #[serde(default)] + pub skipped: Vec, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct ModelEntry { - pub name: String, - pub context_limit: usize, +pub struct RefreshProviderInventorySkipDto { + pub provider_id: String, + pub reason: RefreshProviderInventorySkipReasonDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RefreshProviderInventorySkipReasonDto { + #[default] + UnknownProvider, + NotConfigured, + DoesNotSupportRefresh, + AlreadyRefreshing, } +/// A single model in provider inventory. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct ProviderConfigKey { +pub struct ProviderInventoryModelDto { + /// Model identifier as the provider knows it. + pub id: String, + /// Human-readable display name. pub name: String, - pub required: bool, - pub secret: bool, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub oauth_flow: bool, + /// Model family for grouping in UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + /// Context window size in tokens. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit: Option, + /// Whether the model supports reasoning/extended thinking. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Whether this model should appear in the compact recommended picker. #[serde(default)] - pub device_code_flow: bool, - #[serde(default)] - pub primary: bool, + pub recommended: bool, +} + +/// Provider inventory entry. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryEntryDto { + /// Provider identifier. + pub provider_id: String, + /// Human-readable provider name. + pub provider_name: String, + /// Description of the provider's capabilities. + pub description: String, + /// The default/recommended model for this provider. + pub default_model: String, + /// Whether Goose has enough configuration to use this provider. + pub configured: bool, + /// Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`. + pub provider_type: String, + /// Required configuration keys and setup metadata. + pub config_keys: Vec, + /// Step-by-step setup instructions, when present. + pub setup_steps: Vec, + /// Whether this provider supports background inventory refresh. + pub supports_refresh: bool, + /// Whether a refresh is currently in flight. + pub refreshing: bool, + /// The list of available models. + pub models: Vec, + /// When this entry was last successfully refreshed (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_updated_at: Option, + /// When a refresh was most recently attempted (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_refresh_attempt_at: Option, + /// The last refresh failure message, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_refresh_error: Option, + /// Whether we believe this data may be outdated. + pub stale: bool, + /// Guidance message shown when this provider manages its own model selection externally. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_selection_hint: Option, } /// Empty success response for operations that return no data. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} + +/// List available local Whisper models with their download status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/dictation/models/list", + response = DictationModelsListResponse +)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelsListRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelsListResponse { + pub models: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationLocalModelStatus { + pub id: String, + pub label: String, + pub description: String, + pub size_mb: u32, + pub downloaded: bool, + pub download_in_progress: bool, +} + +/// Kick off a background download of a local Whisper model. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/download", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadRequest { + pub model_id: String, +} + +/// Poll the progress of an in-flight download. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/dictation/models/download/progress", + response = DictationModelDownloadProgressResponse +)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadProgressRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadProgressResponse { + /// None when no download is active for this model id. + pub progress: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationDownloadProgress { + pub bytes_downloaded: u64, + pub total_bytes: u64, + pub progress_percent: f32, + /// serde lowercase of DownloadStatus: "downloading" | "completed" | "failed" | "cancelled" + pub status: String, + pub error: Option, +} + +/// Cancel an in-flight download. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/cancel", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelCancelRequest { + pub model_id: String, +} + +/// Delete a downloaded local Whisper model from disk. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDeleteRequest { + pub model_id: String, +} + +/// Persist the user's model selection for a given provider. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/model/select", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelSelectRequest { + pub provider: String, + pub model_id: String, +} diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 2045caa67c49..46850dd6ad6a 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -2,6 +2,7 @@ name = "goose-server" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 1d18e2b40fac..29825f038834 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -11,6 +11,7 @@ use goose::config::declarative_providers::LoadedProvider; use goose::config::paths::Paths; use goose::config::ExtensionEntry; use goose::config::{Config, ConfigError}; +use goose::custom_requests::SourceType; use goose::model::ModelConfig; use goose::providers::base::{ProviderMetadata, ProviderType}; use goose::providers::canonical::maybe_get_canonical_model; @@ -136,6 +137,7 @@ pub enum CommandType { Builtin, Recipe, Skill, + Agent, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -143,6 +145,10 @@ pub struct SlashCommand { pub command: String, pub help: String, pub command_type: CommandType, + /// For MCP-sourced skills, the name of the originating MCP extension + /// (e.g. "github"). `None` for filesystem skills and non-skill commands. + #[serde(skip_serializing_if = "Option::is_none")] + pub origin: Option, } #[derive(Serialize, ToSchema)] pub struct SlashCommandsResponse { @@ -395,6 +401,9 @@ pub async fn get_provider_models( pub struct SlashCommandsQuery { /// Optional working directory to discover local skills from pub working_dir: Option, + /// Optional session id; when provided, the endpoint also includes + /// MCP-served skills from the corresponding agent's connected extensions. + pub session_id: Option, } #[utoipa::path( @@ -406,6 +415,7 @@ pub struct SlashCommandsQuery { ) )] pub async fn get_slash_commands( + axum::extract::State(state): axum::extract::State>, axum::extract::Query(query): axum::extract::Query, ) -> Result, ErrorResponse> { let mut commands: Vec<_> = slash_commands::list_commands() @@ -414,6 +424,7 @@ pub async fn get_slash_commands( command: command.command.clone(), help: command.recipe_path.clone(), command_type: CommandType::Recipe, + origin: None, }) .collect(); @@ -422,20 +433,53 @@ pub async fn get_slash_commands( command: cmd_def.name.to_string(), help: cmd_def.description.to_string(), command_type: CommandType::Builtin, + origin: None, }); } let working_dir = query.working_dir.map(std::path::PathBuf::from); - for source in - goose::agents::platform_extensions::skills::list_installed_skills(working_dir.as_deref()) - { + for source in goose::skills::list_installed_skills(working_dir.as_deref()) { commands.push(SlashCommand { command: source.name, help: source.description, command_type: CommandType::Skill, + origin: None, }); } + let discover_dir = working_dir + .as_deref() + .unwrap_or_else(|| std::path::Path::new(".")); + for source in + goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir) + { + if matches!( + source.source_type, + SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe + ) && !source.content.is_empty() + { + commands.push(SlashCommand { + command: source.name, + help: source.description, + command_type: CommandType::Agent, + origin: None, + }); + } + } + + if let Some(session_id) = query.session_id.as_deref() { + if let Ok(agent) = state.get_agent(session_id.to_string()).await { + for entry in agent.extension_manager.aggregated_mcp_skills().await { + commands.push(SlashCommand { + command: entry.name, + help: entry.description, + command_type: CommandType::Skill, + origin: Some(entry.server), + }); + } + } + } + Ok(Json(SlashCommandsResponse { commands })) } diff --git a/crates/goose-server/src/tls.rs b/crates/goose-server/src/tls.rs index 8f1431a2139e..273052ec6c3b 100644 --- a/crates/goose-server/src/tls.rs +++ b/crates/goose-server/src/tls.rs @@ -12,6 +12,7 @@ //! the server listener always uses OpenSSL when this feature is active. use anyhow::{bail, Result}; +use goose::config::paths::Paths; use rcgen::{CertificateParams, DnType, KeyPair, SanType}; use std::path::Path; @@ -105,6 +106,65 @@ pub async fn setup_tls(cert_path: Option<&str>, key_path: Option<&str>) -> Resul } } +fn tls_cache_dir() -> std::path::PathBuf { + Paths::config_dir().join("tls") +} + +fn write_private_key(path: &std::path::Path, contents: &[u8]) { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let result = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path); + if let Ok(mut file) = result { + let _ = file.write_all(contents); + } + } + + #[cfg(not(unix))] + { + let _ = std::fs::write(path, contents); + } +} + +async fn load_cached_tls() -> Option { + let dir = tls_cache_dir(); + let cert_pem = std::fs::read(dir.join("server.pem")).ok()?; + let key_pem = std::fs::read(dir.join("server.key")).ok()?; + + let der = pem::parse(&cert_pem).ok()?.into_contents(); + let fingerprint = sha256_fingerprint(&der); + + #[cfg(feature = "rustls-tls")] + let config = axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem) + .await + .ok()?; + #[cfg(feature = "native-tls")] + let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem).ok()?; + + Some(TlsSetup { + config, + fingerprint, + }) +} + +/// All errors are silently ignored — this is a best-effort optimisation and +/// must never prevent the server from starting. +fn save_tls_to_cache(cert_pem: &str, key_pem: &str) { + let dir = tls_cache_dir(); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let _ = std::fs::write(dir.join("server.pem"), cert_pem); + write_private_key(&dir.join("server.key"), key_pem.as_bytes()); +} + /// Generate a self-signed TLS certificate for localhost (127.0.0.1) and /// return a [`TlsSetup`] containing the server config and the SHA-256 /// fingerprint of the generated certificate (colon-separated hex). @@ -115,6 +175,12 @@ pub async fn self_signed_config() -> Result { #[cfg(feature = "rustls-tls")] let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + // Fast path: reuse a previously cached certificate if one exists. + if let Some(cached) = load_cached_tls().await { + println!("GOOSED_CERT_FINGERPRINT={}", cached.fingerprint); + return Ok(cached); + } + let (cert, key_pair) = generate_self_signed_cert()?; let fingerprint = sha256_fingerprint(cert.der()); @@ -123,6 +189,9 @@ pub async fn self_signed_config() -> Result { let cert_pem = cert.pem(); let key_pem = key_pair.serialize_pem(); + // Persist for future restarts before moving the strings into the config. + save_tls_to_cache(&cert_pem, &key_pem); + #[cfg(feature = "rustls-tls")] let config = axum_server::tls_rustls::RustlsConfig::from_pem( cert_pem.into_bytes(), diff --git a/crates/goose-test-support/Cargo.toml b/crates/goose-test-support/Cargo.toml index 169779336fdd..02cd360f6c72 100644 --- a/crates/goose-test-support/Cargo.toml +++ b/crates/goose-test-support/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "goose-test-support" edition.workspace = true +rust-version.workspace = true version.workspace = true authors.workspace = true license.workspace = true diff --git a/crates/goose-test-support/src/session.rs b/crates/goose-test-support/src/session.rs index c150e82cd8af..c3ac411d12c4 100644 --- a/crates/goose-test-support/src/session.rs +++ b/crates/goose-test-support/src/session.rs @@ -1,7 +1,12 @@ use std::sync::{Arc, Mutex}; pub const TEST_SESSION_ID: &str = "test-session-id"; -pub const TEST_MODEL: &str = "gpt-5-nano"; +// Use a Chat Completions model so the canned SSE fixtures (which return +// Chat Completions format) are parsed correctly. gpt-5-nano now routes to +// the Responses API which needs a different mock format. +// TODO: add a Responses API mock to OpenAiFixture so tests can cover +// responses-routed models like gpt-5-nano end-to-end. +pub const TEST_MODEL: &str = "gpt-4.1"; const NOT_YET_SET: &str = "session-id-not-yet-set"; pub(crate) const SESSION_ID_HEADER: &str = "agent-session-id"; diff --git a/crates/goose-test/Cargo.toml b/crates/goose-test/Cargo.toml index bae6ce32d0c7..21f84b372de2 100644 --- a/crates/goose-test/Cargo.toml +++ b/crates/goose-test/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "goose-test" edition.workspace = true +rust-version.workspace = true version.workspace = true authors.workspace = true license.workspace = true diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 0960319d7623..7776b6eb0808 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -2,6 +2,7 @@ name = "goose" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -81,7 +82,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_urlencoded = "0.7" jsonschema = "0.30.0" -uuid = { workspace = true } +uuid = { workspace = true, features = ["v7"] } regex = { workspace = true } async-trait = { workspace = true } async-stream = { workspace = true } @@ -95,7 +96,7 @@ nanoid = "0.4" sha2 = { workspace = true } base64 = { workspace = true } url = { workspace = true } -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } webbrowser = { workspace = true } lazy_static = "1.5.0" tracing = { workspace = true } @@ -113,6 +114,7 @@ strum = { workspace = true } once_cell = { workspace = true } etcetera = { workspace = true } fs-err = "3" +goose-sdk = { path = "../goose-sdk" } rand = { workspace = true } utoipa = { workspace = true, features = ["chrono"] } tokio-cron-scheduler = "0.14.0" @@ -188,6 +190,9 @@ pem = { version = "3", optional = true } pkcs1 = { version = "0.7", default-features = false, features = ["pkcs8"], optional = true } pkcs8 = { version = "0.10", default-features = false, features = ["alloc"], optional = true } sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8"], optional = true } +goose-acp-macros = { path = "../goose-acp-macros" } +tower-http = { workspace = true, features = ["cors"] } +http-body-util = "0.1.3" [target.'cfg(target_os = "windows")'.dependencies] @@ -220,6 +225,7 @@ opentelemetry_sdk = { workspace = true, features = ["testing"] } goose-test-support = { path = "../goose-test-support" } bytes.workspace = true http.workspace = true +goose-mcp = { path = "../goose-mcp" } [[example]] name = "agent" @@ -242,6 +248,10 @@ path = "src/bin/analyze_cli.rs" name = "build_canonical_models" path = "src/providers/canonical/build_canonical_models.rs" +[[bin]] +name = "generate-acp-schema" +path = "src/bin/generate_acp_schema.rs" + [package.metadata.cargo-machete] ignored = [ @@ -249,4 +259,6 @@ ignored = [ "winapi", # Used to provide extras imports for sacp "agent-client-protocol-schema", + # Used via http transport + "http-body-util", ] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json new file mode 100644 index 000000000000..252639c00a3b --- /dev/null +++ b/crates/goose/acp-meta.json @@ -0,0 +1,199 @@ +{ + "methods": [ + { + "method": "_goose/extensions/add", + "requestType": "AddExtensionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/extensions/remove", + "requestType": "RemoveExtensionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/tools", + "requestType": "GetToolsRequest", + "responseType": "GetToolsResponse" + }, + { + "method": "_goose/resource/read", + "requestType": "ReadResourceRequest", + "responseType": "ReadResourceResponse" + }, + { + "method": "_goose/working_dir/update", + "requestType": "UpdateWorkingDirRequest", + "responseType": "EmptyResponse" + }, + { + "method": "session/delete", + "requestType": "DeleteSessionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/config/extensions", + "requestType": "GetExtensionsRequest", + "responseType": "GetExtensionsResponse" + }, + { + "method": "_goose/config/extensions/add", + "requestType": "AddConfigExtensionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/config/extensions/remove", + "requestType": "RemoveConfigExtensionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/config/extensions/toggle", + "requestType": "ToggleConfigExtensionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/session/extensions", + "requestType": "GetSessionExtensionsRequest", + "responseType": "GetSessionExtensionsResponse" + }, + { + "method": "_goose/providers/list", + "requestType": "ListProvidersRequest", + "responseType": "ListProvidersResponse" + }, + { + "method": "_goose/providers/inventory/refresh", + "requestType": "RefreshProviderInventoryRequest", + "responseType": "RefreshProviderInventoryResponse" + }, + { + "method": "_goose/config/read", + "requestType": "ReadConfigRequest", + "responseType": "ReadConfigResponse" + }, + { + "method": "_goose/config/upsert", + "requestType": "UpsertConfigRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/config/remove", + "requestType": "RemoveConfigRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/secret/check", + "requestType": "CheckSecretRequest", + "responseType": "CheckSecretResponse" + }, + { + "method": "_goose/secret/upsert", + "requestType": "UpsertSecretRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/secret/remove", + "requestType": "RemoveSecretRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/session/export", + "requestType": "ExportSessionRequest", + "responseType": "ExportSessionResponse" + }, + { + "method": "_goose/session/import", + "requestType": "ImportSessionRequest", + "responseType": "ImportSessionResponse" + }, + { + "method": "_goose/session/update_project", + "requestType": "UpdateSessionProjectRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/session/rename", + "requestType": "RenameSessionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/session/archive", + "requestType": "ArchiveSessionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/session/unarchive", + "requestType": "UnarchiveSessionRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/sources/create", + "requestType": "CreateSourceRequest", + "responseType": "CreateSourceResponse" + }, + { + "method": "_goose/sources/list", + "requestType": "ListSourcesRequest", + "responseType": "ListSourcesResponse" + }, + { + "method": "_goose/sources/update", + "requestType": "UpdateSourceRequest", + "responseType": "UpdateSourceResponse" + }, + { + "method": "_goose/sources/delete", + "requestType": "DeleteSourceRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/sources/export", + "requestType": "ExportSourceRequest", + "responseType": "ExportSourceResponse" + }, + { + "method": "_goose/sources/import", + "requestType": "ImportSourcesRequest", + "responseType": "ImportSourcesResponse" + }, + { + "method": "_goose/dictation/transcribe", + "requestType": "DictationTranscribeRequest", + "responseType": "DictationTranscribeResponse" + }, + { + "method": "_goose/dictation/config", + "requestType": "DictationConfigRequest", + "responseType": "DictationConfigResponse" + }, + { + "method": "_goose/dictation/models/list", + "requestType": "DictationModelsListRequest", + "responseType": "DictationModelsListResponse" + }, + { + "method": "_goose/dictation/models/download", + "requestType": "DictationModelDownloadRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/models/download/progress", + "requestType": "DictationModelDownloadProgressRequest", + "responseType": "DictationModelDownloadProgressResponse" + }, + { + "method": "_goose/dictation/models/cancel", + "requestType": "DictationModelCancelRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/models/delete", + "requestType": "DictationModelDeleteRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/model/select", + "requestType": "DictationModelSelectRequest", + "responseType": "EmptyResponse" + } + ] +} diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json new file mode 100644 index 000000000000..1931938eabe2 --- /dev/null +++ b/crates/goose/acp-schema.json @@ -0,0 +1,2013 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GooseExtensions", + "$defs": { + "AddExtensionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "config": { + "description": "Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).", + "default": null + } + }, + "required": [ + "sessionId" + ], + "description": "Add an extension to an active session.", + "x-side": "agent", + "x-method": "_goose/extensions/add" + }, + "EmptyResponse": { + "type": "object", + "description": "Empty success response for operations that return no data.", + "x-side": "agent" + }, + "RemoveExtensionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "sessionId", + "name" + ], + "description": "Remove an extension from an active session.", + "x-side": "agent", + "x-method": "_goose/extensions/remove" + }, + "GetToolsRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "List all tools available in a session.", + "x-side": "agent", + "x-method": "_goose/tools" + }, + "GetToolsResponse": { + "type": "object", + "properties": { + "tools": { + "type": "array", + "items": {}, + "description": "Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`." + } + }, + "required": [ + "tools" + ], + "description": "Tools response.", + "x-side": "agent", + "x-method": "_goose/tools" + }, + "ReadResourceRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "extensionName": { + "type": "string" + } + }, + "required": [ + "sessionId", + "uri", + "extensionName" + ], + "description": "Read a resource from an extension.", + "x-side": "agent", + "x-method": "_goose/resource/read" + }, + "ReadResourceResponse": { + "type": "object", + "properties": { + "result": { + "description": "The resource result from the extension (MCP ReadResourceResult).", + "default": null + } + }, + "description": "Resource read response.", + "x-side": "agent", + "x-method": "_goose/resource/read" + }, + "UpdateWorkingDirRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "workingDir": { + "type": "string" + } + }, + "required": [ + "sessionId", + "workingDir" + ], + "description": "Update the working directory for a session.", + "x-side": "agent", + "x-method": "_goose/working_dir/update" + }, + "DeleteSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Delete a session.", + "x-side": "agent", + "x-method": "session/delete" + }, + "GetExtensionsRequest": { + "type": "object", + "description": "List configured extensions and any warnings.", + "x-side": "agent", + "x-method": "_goose/config/extensions" + }, + "GetExtensionsResponse": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "items": {}, + "description": "Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "extensions", + "warnings" + ], + "description": "List configured extensions and any warnings.", + "x-side": "agent", + "x-method": "_goose/config/extensions" + }, + "AddConfigExtensionRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "extensionConfig": { + "description": "Extension configuration. Must be a JSON object matching one of the\n`ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`).\n`name` and `enabled` are injected server-side.", + "default": null + }, + "enabled": { + "type": "boolean", + "default": false + } + }, + "required": [ + "name" + ], + "description": "Persist a new extension to the user's global goose config.", + "x-side": "agent", + "x-method": "_goose/config/extensions/add" + }, + "RemoveConfigExtensionRequest": { + "type": "object", + "properties": { + "configKey": { + "type": "string" + } + }, + "required": [ + "configKey" + ], + "description": "Remove a persisted extension from the user's global goose config.", + "x-side": "agent", + "x-method": "_goose/config/extensions/remove" + }, + "ToggleConfigExtensionRequest": { + "type": "object", + "properties": { + "configKey": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "configKey", + "enabled" + ], + "description": "Toggle the `enabled` flag for a persisted extension in the user's global goose config.", + "x-side": "agent", + "x-method": "_goose/config/extensions/toggle" + }, + "GetSessionExtensionsRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "x-side": "agent", + "x-method": "_goose/session/extensions" + }, + "GetSessionExtensionsResponse": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "items": {} + } + }, + "required": [ + "extensions" + ], + "x-side": "agent", + "x-method": "_goose/session/extensions" + }, + "ListProvidersRequest": { + "type": "object", + "properties": { + "providerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only return entries for these providers. Empty means all.", + "default": [] + } + }, + "description": "List providers with setup metadata and the current model inventory snapshot.", + "x-side": "agent", + "x-method": "_goose/providers/list" + }, + "ListProvidersResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderInventoryEntryDto" + } + } + }, + "required": [ + "entries" + ], + "description": "Provider list response.", + "x-side": "agent", + "x-method": "_goose/providers/list" + }, + "ProviderInventoryEntryDto": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider identifier." + }, + "providerName": { + "type": "string", + "description": "Human-readable provider name." + }, + "description": { + "type": "string", + "description": "Description of the provider's capabilities." + }, + "defaultModel": { + "type": "string", + "description": "The default/recommended model for this provider." + }, + "configured": { + "type": "boolean", + "description": "Whether Goose has enough configuration to use this provider." + }, + "providerType": { + "type": "string", + "description": "Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`." + }, + "configKeys": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderConfigKey" + }, + "description": "Required configuration keys and setup metadata." + }, + "setupSteps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Step-by-step setup instructions, when present." + }, + "supportsRefresh": { + "type": "boolean", + "description": "Whether this provider supports background inventory refresh." + }, + "refreshing": { + "type": "boolean", + "description": "Whether a refresh is currently in flight." + }, + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderInventoryModelDto" + }, + "description": "The list of available models." + }, + "lastUpdatedAt": { + "type": [ + "string", + "null" + ], + "description": "When this entry was last successfully refreshed (ISO 8601)." + }, + "lastRefreshAttemptAt": { + "type": [ + "string", + "null" + ], + "description": "When a refresh was most recently attempted (ISO 8601)." + }, + "lastRefreshError": { + "type": [ + "string", + "null" + ], + "description": "The last refresh failure message, if any." + }, + "stale": { + "type": "boolean", + "description": "Whether we believe this data may be outdated." + }, + "modelSelectionHint": { + "type": [ + "string", + "null" + ], + "description": "Guidance message shown when this provider manages its own model selection externally." + } + }, + "required": [ + "providerId", + "providerName", + "description", + "defaultModel", + "configured", + "providerType", + "configKeys", + "setupSteps", + "supportsRefresh", + "refreshing", + "models", + "stale" + ], + "description": "Provider inventory entry." + }, + "ProviderConfigKey": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "secret": { + "type": "boolean" + }, + "default": { + "type": [ + "string", + "null" + ], + "default": null + }, + "oauthFlow": { + "type": "boolean", + "default": false + }, + "deviceCodeFlow": { + "type": "boolean", + "default": false + }, + "primary": { + "type": "boolean", + "default": false + } + }, + "required": [ + "name", + "required", + "secret" + ] + }, + "ProviderInventoryModelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Model identifier as the provider knows it." + }, + "name": { + "type": "string", + "description": "Human-readable display name." + }, + "family": { + "type": [ + "string", + "null" + ], + "description": "Model family for grouping in UI." + }, + "contextLimit": { + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "description": "Context window size in tokens." + }, + "reasoning": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the model supports reasoning/extended thinking." + }, + "recommended": { + "type": "boolean", + "description": "Whether this model should appear in the compact recommended picker.", + "default": false + } + }, + "required": [ + "id", + "name" + ], + "description": "A single model in provider inventory." + }, + "RefreshProviderInventoryRequest": { + "type": "object", + "properties": { + "providerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which providers to refresh. Empty means all known providers.", + "default": [] + } + }, + "description": "Trigger a background refresh of provider inventories.", + "x-side": "agent", + "x-method": "_goose/providers/inventory/refresh" + }, + "RefreshProviderInventoryResponse": { + "type": "object", + "properties": { + "started": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which providers will be refreshed." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/$defs/RefreshProviderInventorySkipDto" + }, + "description": "Which providers were skipped and why.", + "default": [] + } + }, + "required": [ + "started" + ], + "description": "Refresh acknowledgement.", + "x-side": "agent", + "x-method": "_goose/providers/inventory/refresh" + }, + "RefreshProviderInventorySkipDto": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "reason": { + "$ref": "#/$defs/RefreshProviderInventorySkipReasonDto" + } + }, + "required": [ + "providerId", + "reason" + ] + }, + "RefreshProviderInventorySkipReasonDto": { + "type": "string", + "enum": [ + "unknown_provider", + "not_configured", + "does_not_support_refresh", + "already_refreshing" + ] + }, + "ReadConfigRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "description": "Read a single non-secret config value.", + "x-side": "agent", + "x-method": "_goose/config/read" + }, + "ReadConfigResponse": { + "type": "object", + "properties": { + "value": { + "default": null + } + }, + "description": "Config read response.", + "x-side": "agent", + "x-method": "_goose/config/read" + }, + "UpsertConfigRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": {} + }, + "required": [ + "key", + "value" + ], + "description": "Upsert a single non-secret config value.", + "x-side": "agent", + "x-method": "_goose/config/upsert" + }, + "RemoveConfigRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "description": "Remove a single non-secret config value.", + "x-side": "agent", + "x-method": "_goose/config/remove" + }, + "CheckSecretRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "description": "Check whether a secret exists. Never returns the actual value.", + "x-side": "agent", + "x-method": "_goose/secret/check" + }, + "CheckSecretResponse": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + }, + "required": [ + "exists" + ], + "description": "Secret check response.", + "x-side": "agent", + "x-method": "_goose/secret/check" + }, + "UpsertSecretRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": {} + }, + "required": [ + "key", + "value" + ], + "description": "Set a secret value (write-only).", + "x-side": "agent", + "x-method": "_goose/secret/upsert" + }, + "RemoveSecretRequest": { + "type": "object", + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "description": "Remove a secret.", + "x-side": "agent", + "x-method": "_goose/secret/remove" + }, + "ExportSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Export a session as a JSON string.", + "x-side": "agent", + "x-method": "_goose/session/export" + }, + "ExportSessionResponse": { + "type": "object", + "properties": { + "data": { + "type": "string" + } + }, + "required": [ + "data" + ], + "description": "Export session response — raw JSON of the goose session with `conversation`.", + "x-side": "agent", + "x-method": "_goose/session/export" + }, + "ImportSessionRequest": { + "type": "object", + "properties": { + "data": { + "type": "string" + } + }, + "required": [ + "data" + ], + "description": "Import a session from a JSON string.", + "x-side": "agent", + "x-method": "_goose/session/import" + }, + "ImportSessionResponse": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "updatedAt": { + "type": [ + "string", + "null" + ] + }, + "messageCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "sessionId", + "messageCount" + ], + "description": "Import session response — metadata about the newly created session.", + "x-side": "agent", + "x-method": "_goose/session/import" + }, + "UpdateSessionProjectRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "projectId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "sessionId" + ], + "description": "Update the project association for a session.", + "x-side": "agent", + "x-method": "_goose/session/update_project" + }, + "RenameSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "sessionId", + "title" + ], + "description": "Rename a session.", + "x-side": "agent", + "x-method": "_goose/session/rename" + }, + "ArchiveSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Archive a session (soft delete).", + "x-side": "agent", + "x-method": "_goose/session/archive" + }, + "UnarchiveSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "description": "Unarchive a previously archived session.", + "x-side": "agent", + "x-method": "_goose/session/unarchive" + }, + "CreateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ], + "description": "Absolute path to the project root. Required when `global` is false." + } + }, + "required": [ + "type", + "name", + "description", + "content", + "global" + ], + "description": "Create a new source in an explicit target scope (global or project-scoped).", + "x-side": "agent", + "x-method": "_goose/sources/create" + }, + "SourceType": { + "type": "string", + "enum": [ + "skill", + "builtinSkill", + "recipe", + "subrecipe", + "agent" + ], + "description": "The type of source entity." + }, + "CreateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/create" + }, + "SourceEntry": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "directory": { + "type": "string", + "description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents." + }, + "global": { + "type": "boolean", + "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." + }, + "supportingFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths (absolute) of additional files that live alongside the source.\nOnly skills currently populate this; empty for other source types." + } + }, + "required": [ + "type", + "name", + "description", + "content", + "directory", + "global" + ], + "description": "A source discovered by Goose and backed by an on-disk path. Sources may be\neither `global` (shared across all projects) or project-specific." + }, + "ListSourcesRequest": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/SourceType" + }, + { + "type": "null" + } + ] + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "description": "List discovered sources.\n\nToday this endpoint only returns skills. If `type` is omitted, it defaults\nto listing skill sources. Both global and project-scoped skills are included\nwhen `project_dir` is set.", + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "ListSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "UpdateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "type", + "path", + "name", + "description", + "content" + ], + "description": "Update an existing source's name, description, and content by absolute path.", + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "UpdateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "DeleteSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "description": "Delete a source and its on-disk directory by absolute path.", + "x-side": "agent", + "x-method": "_goose/sources/delete" + }, + "ExportSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "description": "Export a source at an absolute path as a portable JSON payload.", + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ExportSourceResponse": { + "type": "object", + "properties": { + "json": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "json", + "filename" + ], + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ImportSourcesRequest": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data", + "global" + ], + "description": "Import a source from a JSON export payload produced by `_goose/sources/export`.\nThe imported source is written into the explicit target scope; on name\ncollisions a `-imported` suffix is appended.", + "x-side": "agent", + "x-method": "_goose/sources/import" + }, + "ImportSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/import" + }, + "DictationTranscribeRequest": { + "type": "object", + "properties": { + "audio": { + "type": "string", + "description": "Base64-encoded audio data" + }, + "mimeType": { + "type": "string", + "description": "MIME type (e.g. \"audio/wav\", \"audio/webm\")" + }, + "provider": { + "type": "string", + "description": "Provider to use: \"openai\", \"groq\", \"elevenlabs\", or \"local\"" + } + }, + "required": [ + "audio", + "mimeType", + "provider" + ], + "description": "Transcribe audio via a dictation provider.", + "x-side": "agent", + "x-method": "_goose/dictation/transcribe" + }, + "DictationTranscribeResponse": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "description": "Transcription result.", + "x-side": "agent", + "x-method": "_goose/dictation/transcribe" + }, + "DictationConfigRequest": { + "type": "object", + "description": "Get the configuration status of all dictation providers.", + "x-side": "agent", + "x-method": "_goose/dictation/config" + }, + "DictationConfigResponse": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/DictationProviderStatusEntry" + } + } + }, + "required": [ + "providers" + ], + "description": "Dictation config response — map of provider name to status.", + "x-side": "agent", + "x-method": "_goose/dictation/config" + }, + "DictationProviderStatusEntry": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "host": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "usesProviderConfig": { + "type": "boolean" + }, + "settingsPath": { + "type": [ + "string", + "null" + ] + }, + "configKey": { + "type": [ + "string", + "null" + ] + }, + "modelConfigKey": { + "type": [ + "string", + "null" + ] + }, + "defaultModel": { + "type": [ + "string", + "null" + ] + }, + "selectedModel": { + "type": [ + "string", + "null" + ] + }, + "availableModels": { + "type": "array", + "items": { + "$ref": "#/$defs/DictationModelOption" + }, + "default": [] + } + }, + "required": [ + "configured", + "description", + "usesProviderConfig" + ], + "description": "Per-provider configuration status." + }, + "DictationModelOption": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "description" + ] + }, + "DictationModelsListRequest": { + "type": "object", + "description": "List available local Whisper models with their download status.", + "x-side": "agent", + "x-method": "_goose/dictation/models/list" + }, + "DictationModelsListResponse": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/DictationLocalModelStatus" + } + } + }, + "required": [ + "models" + ], + "x-side": "agent", + "x-method": "_goose/dictation/models/list" + }, + "DictationLocalModelStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sizeMb": { + "type": "integer", + "minimum": 0 + }, + "downloaded": { + "type": "boolean" + }, + "downloadInProgress": { + "type": "boolean" + } + }, + "required": [ + "id", + "label", + "description", + "sizeMb", + "downloaded", + "downloadInProgress" + ] + }, + "DictationModelDownloadRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Kick off a background download of a local Whisper model.", + "x-side": "agent", + "x-method": "_goose/dictation/models/download" + }, + "DictationModelDownloadProgressRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Poll the progress of an in-flight download.", + "x-side": "agent", + "x-method": "_goose/dictation/models/download/progress" + }, + "DictationModelDownloadProgressResponse": { + "type": "object", + "properties": { + "progress": { + "anyOf": [ + { + "$ref": "#/$defs/DictationDownloadProgress" + }, + { + "type": "null" + } + ], + "description": "None when no download is active for this model id." + } + }, + "x-side": "agent", + "x-method": "_goose/dictation/models/download/progress" + }, + "DictationDownloadProgress": { + "type": "object", + "properties": { + "bytesDownloaded": { + "type": "integer", + "minimum": 0 + }, + "totalBytes": { + "type": "integer", + "minimum": 0 + }, + "progressPercent": { + "type": "number", + "format": "float" + }, + "status": { + "type": "string", + "description": "serde lowercase of DownloadStatus: \"downloading\" | \"completed\" | \"failed\" | \"cancelled\"" + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "bytesDownloaded", + "totalBytes", + "progressPercent", + "status" + ] + }, + "DictationModelCancelRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Cancel an in-flight download.", + "x-side": "agent", + "x-method": "_goose/dictation/models/cancel" + }, + "DictationModelDeleteRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Delete a downloaded local Whisper model from disk.", + "x-side": "agent", + "x-method": "_goose/dictation/models/delete" + }, + "DictationModelSelectRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "modelId": { + "type": "string" + } + }, + "required": [ + "provider", + "modelId" + ], + "description": "Persist the user's model selection for a given provider.", + "x-side": "agent", + "x-method": "_goose/dictation/model/select" + }, + "ExtRequest": { + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/AddExtensionRequest" + } + ], + "description": "Params for _goose/extensions/add", + "title": "AddExtensionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RemoveExtensionRequest" + } + ], + "description": "Params for _goose/extensions/remove", + "title": "RemoveExtensionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetToolsRequest" + } + ], + "description": "Params for _goose/tools", + "title": "GetToolsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadResourceRequest" + } + ], + "description": "Params for _goose/resource/read", + "title": "ReadResourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateWorkingDirRequest" + } + ], + "description": "Params for _goose/working_dir/update", + "title": "UpdateWorkingDirRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteSessionRequest" + } + ], + "description": "Params for session/delete", + "title": "DeleteSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetExtensionsRequest" + } + ], + "description": "Params for _goose/config/extensions", + "title": "GetExtensionsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/AddConfigExtensionRequest" + } + ], + "description": "Params for _goose/config/extensions/add", + "title": "AddConfigExtensionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RemoveConfigExtensionRequest" + } + ], + "description": "Params for _goose/config/extensions/remove", + "title": "RemoveConfigExtensionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ToggleConfigExtensionRequest" + } + ], + "description": "Params for _goose/config/extensions/toggle", + "title": "ToggleConfigExtensionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionExtensionsRequest" + } + ], + "description": "Params for _goose/session/extensions", + "title": "GetSessionExtensionsRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListProvidersRequest" + } + ], + "description": "Params for _goose/providers/list", + "title": "ListProvidersRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RefreshProviderInventoryRequest" + } + ], + "description": "Params for _goose/providers/inventory/refresh", + "title": "RefreshProviderInventoryRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadConfigRequest" + } + ], + "description": "Params for _goose/config/read", + "title": "ReadConfigRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpsertConfigRequest" + } + ], + "description": "Params for _goose/config/upsert", + "title": "UpsertConfigRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RemoveConfigRequest" + } + ], + "description": "Params for _goose/config/remove", + "title": "RemoveConfigRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CheckSecretRequest" + } + ], + "description": "Params for _goose/secret/check", + "title": "CheckSecretRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpsertSecretRequest" + } + ], + "description": "Params for _goose/secret/upsert", + "title": "UpsertSecretRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RemoveSecretRequest" + } + ], + "description": "Params for _goose/secret/remove", + "title": "RemoveSecretRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSessionRequest" + } + ], + "description": "Params for _goose/session/export", + "title": "ExportSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSessionRequest" + } + ], + "description": "Params for _goose/session/import", + "title": "ImportSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSessionProjectRequest" + } + ], + "description": "Params for _goose/session/update_project", + "title": "UpdateSessionProjectRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RenameSessionRequest" + } + ], + "description": "Params for _goose/session/rename", + "title": "RenameSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ArchiveSessionRequest" + } + ], + "description": "Params for _goose/session/archive", + "title": "ArchiveSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UnarchiveSessionRequest" + } + ], + "description": "Params for _goose/session/unarchive", + "title": "UnarchiveSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceRequest" + } + ], + "description": "Params for _goose/sources/create", + "title": "CreateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesRequest" + } + ], + "description": "Params for _goose/sources/list", + "title": "ListSourcesRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceRequest" + } + ], + "description": "Params for _goose/sources/update", + "title": "UpdateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteSourceRequest" + } + ], + "description": "Params for _goose/sources/delete", + "title": "DeleteSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceRequest" + } + ], + "description": "Params for _goose/sources/export", + "title": "ExportSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesRequest" + } + ], + "description": "Params for _goose/sources/import", + "title": "ImportSourcesRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationTranscribeRequest" + } + ], + "description": "Params for _goose/dictation/transcribe", + "title": "DictationTranscribeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationConfigRequest" + } + ], + "description": "Params for _goose/dictation/config", + "title": "DictationConfigRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelsListRequest" + } + ], + "description": "Params for _goose/dictation/models/list", + "title": "DictationModelsListRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadRequest" + } + ], + "description": "Params for _goose/dictation/models/download", + "title": "DictationModelDownloadRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadProgressRequest" + } + ], + "description": "Params for _goose/dictation/models/download/progress", + "title": "DictationModelDownloadProgressRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelCancelRequest" + } + ], + "description": "Params for _goose/dictation/models/cancel", + "title": "DictationModelCancelRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDeleteRequest" + } + ], + "description": "Params for _goose/dictation/models/delete", + "title": "DictationModelDeleteRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelSelectRequest" + } + ], + "description": "Params for _goose/dictation/model/select", + "title": "DictationModelSelectRequest" + } + ] + }, + { + "description": "Untyped params", + "type": [ + "object", + "null" + ] + } + ] + } + }, + "required": [ + "id", + "method" + ], + "type": "object", + "x-docs-ignore": true + }, + "ExtResponse": { + "anyOf": [ + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/EmptyResponse" + } + ], + "title": "EmptyResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetToolsResponse" + } + ], + "title": "GetToolsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadResourceResponse" + } + ], + "title": "ReadResourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetExtensionsResponse" + } + ], + "title": "GetExtensionsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionExtensionsResponse" + } + ], + "title": "GetSessionExtensionsResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListProvidersResponse" + } + ], + "title": "ListProvidersResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RefreshProviderInventoryResponse" + } + ], + "title": "RefreshProviderInventoryResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ReadConfigResponse" + } + ], + "title": "ReadConfigResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CheckSecretResponse" + } + ], + "title": "CheckSecretResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSessionResponse" + } + ], + "title": "ExportSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSessionResponse" + } + ], + "title": "ImportSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceResponse" + } + ], + "title": "CreateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesResponse" + } + ], + "title": "ListSourcesResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceResponse" + } + ], + "title": "UpdateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceResponse" + } + ], + "title": "ExportSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesResponse" + } + ], + "title": "ImportSourcesResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationTranscribeResponse" + } + ], + "title": "DictationTranscribeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationConfigResponse" + } + ], + "title": "DictationConfigResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelsListResponse" + } + ], + "title": "DictationModelsListResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadProgressResponse" + } + ], + "title": "DictationModelDownloadProgressResponse" + } + ] + }, + { + "description": "Untyped result" + } + ] + } + }, + "required": [ + "id" + ], + "title": "Success", + "type": "object" + }, + { + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": {} + }, + "required": [ + "code", + "message" + ] + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "error" + ], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true + } + }, + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ], + "description": "Extension request (client → agent)", + "title": "Request" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ], + "description": "Extension response (agent → client)", + "title": "Response" + } + ] +} diff --git a/crates/goose-acp/src/adapters.rs b/crates/goose/src/acp/adapters.rs similarity index 100% rename from crates/goose-acp/src/adapters.rs rename to crates/goose/src/acp/adapters.rs diff --git a/crates/goose-acp/src/fs.rs b/crates/goose/src/acp/fs.rs similarity index 96% rename from crates/goose-acp/src/fs.rs rename to crates/goose/src/acp/fs.rs index 7677deae3f4e..89381a252f0c 100644 --- a/crates/goose-acp/src/fs.rs +++ b/crates/goose/src/acp/fs.rs @@ -1,13 +1,13 @@ -use crate::tools::AcpAwareToolMeta; +use crate::acp::tools::AcpAwareToolMeta; +use crate::agents::mcp_client::{Error as McpError, McpClientTrait}; +use crate::agents::platform_extensions::developer::edit::{ + resolve_path, string_replace, FileEditParams, FileReadParams, FileWriteParams, +}; +use crate::agents::platform_extensions::developer::shell::{ShellParams, OUTPUT_LIMIT_BYTES}; +use crate::agents::platform_extensions::developer::DeveloperClient; use agent_client_protocol_schema::TerminalId; use async_trait::async_trait; use fs_err as fs; -use goose::agents::mcp_client::{Error as McpError, McpClientTrait}; -use goose::agents::platform_extensions::developer::edit::{ - resolve_path, string_replace, FileEditParams, FileReadParams, FileWriteParams, -}; -use goose::agents::platform_extensions::developer::shell::{ShellParams, OUTPUT_LIMIT_BYTES}; -use goose::agents::platform_extensions::developer::DeveloperClient; use rmcp::model::{CallToolResult, Content as RmcpContent, Tool, ToolAnnotations}; use sacp::schema::{ CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest, @@ -93,7 +93,7 @@ fn read_tool() -> Tool { } impl AcpTools { - fn update_tool_call(&self, ctx: &goose::agents::ToolCallContext, fields: ToolCallUpdateFields) { + fn update_tool_call(&self, ctx: &crate::agents::ToolCallContext, fields: ToolCallUpdateFields) { if let Some(ref req_id) = ctx.tool_call_request_id { let _ = self .cx @@ -125,7 +125,7 @@ impl AcpTools { async fn acp_read( &self, arguments: Option, - ctx: &goose::agents::ToolCallContext, + ctx: &crate::agents::ToolCallContext, ) -> Result { let params: FileReadParams = match Self::parse_args(arguments) { Ok(p) => p, @@ -150,7 +150,7 @@ impl AcpTools { async fn acp_write( &self, arguments: Option, - ctx: &goose::agents::ToolCallContext, + ctx: &crate::agents::ToolCallContext, ) -> Result { let params: FileWriteParams = match Self::parse_args(arguments) { Ok(p) => p, @@ -187,7 +187,7 @@ impl AcpTools { async fn acp_edit( &self, arguments: Option, - ctx: &goose::agents::ToolCallContext, + ctx: &crate::agents::ToolCallContext, ) -> Result { let params: FileEditParams = match Self::parse_args(arguments) { Ok(p) => p, @@ -240,7 +240,7 @@ impl AcpTools { async fn acp_shell( &self, arguments: Option, - ctx: &goose::agents::ToolCallContext, + ctx: &crate::agents::ToolCallContext, ) -> Result { let params: ShellParams = match Self::parse_args(arguments) { Ok(p) => p, @@ -404,7 +404,7 @@ impl McpClientTrait for AcpTools { async fn call_tool( &self, - ctx: &goose::agents::ToolCallContext, + ctx: &crate::agents::ToolCallContext, name: &str, arguments: Option, cancellation_token: CancellationToken, diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs index 6e995948f16b..3887b45b2887 100644 --- a/crates/goose/src/acp/mod.rs +++ b/crates/goose/src/acp/mod.rs @@ -1,7 +1,14 @@ +mod adapters; mod common; +pub(crate) mod fs; mod provider; +pub mod server; +pub mod server_factory; +pub(crate) mod tools; +pub mod transport; pub use common::{map_permission_response, PermissionDecision}; +pub use goose_sdk::custom_requests; pub use provider::{ extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL, }; diff --git a/crates/goose-acp/src/server.rs b/crates/goose/src/acp/server.rs similarity index 61% rename from crates/goose-acp/src/server.rs rename to crates/goose/src/acp/server.rs index eeeae74df0de..f105412f9757 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose/src/acp/server.rs @@ -1,46 +1,58 @@ -use crate::custom_requests::*; -use crate::fs::AcpTools; -use crate::tools::AcpAwareToolMeta; +use crate::acp::custom_requests::*; +use crate::acp::fs::AcpTools; +use crate::acp::tools::AcpAwareToolMeta; +use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; +use crate::agents::extension::{Envs, PLATFORM_EXTENSIONS}; +use crate::agents::mcp_client::McpClientTrait; +use crate::agents::platform_extensions::developer::DeveloperClient; +use crate::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; +use crate::config::base::CONFIG_YAML_NAME; +use crate::config::extensions::get_enabled_extensions_with_config; +use crate::config::paths::Paths; +use crate::config::permission::PermissionManager; +use crate::config::{Config, GooseMode}; +use crate::conversation::message::{ActionRequiredData, Message, MessageContent}; +#[cfg(feature = "local-inference")] +use crate::dictation::providers::transcribe_local; +use crate::dictation::providers::{ + all_providers, is_configured, transcribe_with_provider, DictationProvider, +}; +#[cfg(feature = "local-inference")] +use crate::dictation::whisper; +use crate::mcp_utils::ToolResult; +use crate::permission::permission_confirmation::PrincipalType; +use crate::permission::{Permission, PermissionConfirmation}; +use crate::providers::base::Provider; +use crate::providers::inventory::{ + ProviderInventoryEntry, ProviderInventoryService, RefreshSkipReason, +}; +use crate::session::session_manager::SessionType; +use crate::session::{EnabledExtensionsState, Session, SessionManager}; +use crate::utils::sanitize_unicode_tags; use anyhow::Result; use fs_err as fs; -use futures::future::BoxFuture; -use goose::acp::{PermissionDecision, ACP_CURRENT_MODEL}; -use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS}; -use goose::agents::mcp_client::McpClientTrait; -use goose::agents::platform_extensions::developer::DeveloperClient; -use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; -use goose::builtin_extension::register_builtin_extensions; -use goose::config::base::CONFIG_YAML_NAME; -use goose::config::extensions::get_enabled_extensions_with_config; -use goose::config::paths::Paths; -use goose::config::permission::PermissionManager; -use goose::config::{Config, GooseMode}; -use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; -use goose::mcp_utils::ToolResult; -use goose::permission::permission_confirmation::PrincipalType; -use goose::permission::{Permission, PermissionConfirmation}; -use goose::providers::base::Provider; -use goose::session::session_manager::SessionType; -use goose::session::{EnabledExtensionsState, Session, SessionManager}; +use futures::future::{BoxFuture, Either}; use goose_acp_macros::custom_methods; -use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role}; +use rmcp::model::{ + AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, +}; use sacp::schema::{ - AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, - BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, - ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, - EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse, - ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, - RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, - SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, - SessionInfo, SessionListCapabilities, SessionMode, SessionModeId, SessionModeState, - SessionModelState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, - SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, - ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, - ToolCallUpdateFields, ToolKind, + AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest, + AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest, + CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk, + CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, + ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, McpServer, Meta, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, + SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOption, SessionId, SessionInfo, SessionListCapabilities, SessionMode, + SessionModeId, SessionModeState, SessionModelState, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, + TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, }; use sacp::util::MatchDispatchFrom; use sacp::{ @@ -59,15 +71,49 @@ use url::Url; pub type AcpProviderFactory = Arc< dyn Fn( String, - goose::model::ModelConfig, + crate::model::ModelConfig, Vec, ) -> BoxFuture<'static, Result>> + Send + Sync, >; +/// Convenience conversions from any `Display` error into an `sacp::Error`. +/// +/// Replaces the repetitive `.internal_err()` +/// pattern. Use `.internal_err()?` for server-side failures and `.invalid_params_err()?` +/// for bad client input. For custom messages use `.internal_err_ctx("context")?`. +#[allow(dead_code)] +trait ResultExt { + fn internal_err(self) -> Result; + fn invalid_params_err(self) -> Result; + fn internal_err_ctx(self, context: &str) -> Result; + fn invalid_params_err_ctx(self, context: &str) -> Result; +} + +impl ResultExt for Result { + fn internal_err(self) -> Result { + self.map_err(|e| sacp::Error::internal_error().data(e.to_string())) + } + fn invalid_params_err(self) -> Result { + self.map_err(|e| sacp::Error::invalid_params().data(e.to_string())) + } + fn internal_err_ctx(self, context: &str) -> Result { + self.map_err(|e| sacp::Error::internal_error().data(format!("{context}: {e}"))) + } + fn invalid_params_err_ctx(self, context: &str) -> Result { + self.map_err(|e| sacp::Error::invalid_params().data(format!("{context}: {e}"))) + } +} + const DEFAULT_PROVIDER_ID: &str = "goose"; const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; +const OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "OPENAI_TRANSCRIPTION_MODEL"; +const GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "GROQ_TRANSCRIPTION_MODEL"; +const ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "ELEVENLABS_TRANSCRIPTION_MODEL"; +const OPENAI_TRANSCRIPTION_MODEL: &str = "whisper-1"; +const GROQ_TRANSCRIPTION_MODEL: &str = "whisper-large-v3-turbo"; +const ELEVENLABS_TRANSCRIPTION_MODEL: &str = "scribe_v1"; /// In-memory state for an active ACP session. /// @@ -90,19 +136,33 @@ const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; struct GooseAcpSession { agent: AgentHandle, internal_session_id: String, - tool_requests: HashMap, + tool_requests: HashMap, cancel_token: Option, /// Working directory set while the agent was still loading. /// Applied once the agent becomes ready. pending_working_dir: Option, } +/// Progress stages signalled by the background agent setup task via the watch +/// channel. `ProviderReady` fires as soon as the provider (and goose-mode) +/// are initialized — before extensions finish loading. `FullyReady` fires +/// once every extension has been loaded (or failed). +#[derive(Clone)] +enum AgentSetupProgress { + /// Provider is initialized; extensions are still loading in the background. + ProviderReady(Arc), + /// Provider *and* all extensions are initialized. + FullyReady(Arc), +} + +type AgentSetupSignal = Option>; + /// The agent may still be initializing in the background (extension loading, /// provider setup). Callers that need the live agent (e.g. `on_prompt`) await /// the handle; callers that only need the session metadata can proceed without it. enum AgentHandle { Ready(Arc), - Loading(tokio::sync::watch::Receiver, String>>>), + Loading(tokio::sync::watch::Receiver), } struct AgentSetupRequest { @@ -111,7 +171,9 @@ struct AgentSetupRequest { mcp_servers: Vec, /// Pre-resolved provider name + model config (from config, no network). /// When present the spawn skips re-deriving these from config. - resolved_provider: Option<(String, goose::model::ModelConfig)>, + resolved_provider: Option<(String, crate::model::ModelConfig)>, + /// Pre-instantiated provider reused from synchronous session initialization. + prebuilt_provider: Option>, } pub struct GooseAcpAgent { @@ -122,10 +184,11 @@ pub struct GooseAcpAgent { client_terminal: OnceCell, config_dir: std::path::PathBuf, session_manager: Arc, - thread_manager: Arc, + thread_manager: Arc, permission_manager: Arc, goose_mode: GooseMode, disable_session_naming: bool, + provider_inventory: ProviderInventoryService, } /// Shorten a session/thread id for perf log correlation. @@ -135,6 +198,55 @@ fn sid_short(id: &str) -> String { id.chars().take(8).collect() } +fn thread_session_meta( + thread: &crate::session::Thread, +) -> serde_json::Map { + let mut meta = serde_json::Map::new(); + meta.insert( + "messageCount".to_string(), + serde_json::Value::Number(thread.message_count.into()), + ); + meta.insert( + "createdAt".to_string(), + serde_json::Value::String(thread.created_at.to_rfc3339()), + ); + if let Some(ref archived_at) = thread.archived_at { + meta.insert( + "archivedAt".to_string(), + serde_json::Value::String(archived_at.to_rfc3339()), + ); + } + meta.insert( + "userSetName".to_string(), + serde_json::Value::Bool(thread.user_set_name), + ); + if let Some(ref pid) = thread.metadata.project_id { + meta.insert( + "projectId".to_string(), + serde_json::Value::String(pid.clone()), + ); + } + if let Some(ref provider_id) = thread.metadata.provider_id { + meta.insert( + "providerId".to_string(), + serde_json::Value::String(provider_id.clone()), + ); + } + if let Some(ref model_id) = thread.metadata.model_id { + meta.insert( + "modelId".to_string(), + serde_json::Value::String(model_id.clone()), + ); + } + if let Some(ref persona_id) = thread.metadata.persona_id { + meta.insert( + "personaId".to_string(), + serde_json::Value::String(persona_id.clone()), + ); + } + meta +} + fn extract_timeout_from_meta(meta: &Option) -> Option { meta.as_ref() .and_then(|m| m.get("timeout")) @@ -201,7 +313,7 @@ fn is_developer_file_tool(tool_name: &str) -> bool { } fn extract_locations_from_meta( - tool_response: &goose::conversation::message::ToolResponse, + tool_response: &crate::conversation::message::ToolResponse, ) -> Option> { let result = tool_response.tool_result.as_ref().ok()?; let meta = result.meta.as_ref()?; @@ -223,8 +335,8 @@ fn extract_locations_from_meta( } fn extract_tool_locations( - tool_request: &goose::conversation::message::ToolRequest, - tool_response: &goose::conversation::message::ToolResponse, + tool_request: &crate::conversation::message::ToolRequest, + tool_response: &crate::conversation::message::ToolResponse, ) -> Vec { let mut locations = Vec::new(); @@ -366,7 +478,7 @@ fn summarize_tool_call(tool_name: &str, arguments: Option<&serde_json::Value>) - if !s.is_empty() { let first_line = s.lines().next().unwrap_or(&s); if first_line.len() > 60 { - return Some(format!("{}…", goose::utils::safe_truncate(first_line, 57))); + return Some(format!("{}…", crate::utils::safe_truncate(first_line, 57))); } return Some(first_line.to_string()); } @@ -402,26 +514,83 @@ fn builtin_to_extension_config(name: &str) -> ExtensionConfig { } } -async fn build_model_state(provider: &dyn Provider) -> Result { - let models = provider - .fetch_recommended_models() - .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let current_model = &provider.get_model_config().model_name; - Ok(SessionModelState::new( - ModelId::new(current_model.as_str()), - models - .iter() - .map(|name| ModelInfo::new(ModelId::new(&**name), &**name)) +fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEntryDto { + let stale = ProviderInventoryService::is_stale(&entry); + ProviderInventoryEntryDto { + provider_id: entry.provider_id, + provider_name: entry.provider_name, + description: entry.description, + default_model: entry.default_model, + configured: entry.configured, + provider_type: format!("{:?}", entry.provider_type), + config_keys: entry + .config_keys + .into_iter() + .map(provider_config_key_to_dto) .collect(), - )) + setup_steps: entry.setup_steps, + supports_refresh: entry.supports_refresh, + refreshing: entry.refreshing, + models: entry + .models + .into_iter() + .map(|m| ProviderInventoryModelDto { + id: m.id, + name: m.name, + family: m.family, + context_limit: m.context_limit, + reasoning: m.reasoning, + recommended: m.recommended, + }) + .collect(), + last_updated_at: entry.last_updated_at.map(|t| t.to_rfc3339()), + last_refresh_attempt_at: entry.last_refresh_attempt_at.map(|t| t.to_rfc3339()), + last_refresh_error: entry.last_refresh_error, + stale, + model_selection_hint: entry.model_selection_hint, + } +} + +fn provider_config_key_to_dto(key: crate::providers::base::ConfigKey) -> ProviderConfigKey { + ProviderConfigKey { + name: key.name, + required: key.required, + secret: key.secret, + default: key.default, + oauth_flow: key.oauth_flow, + device_code_flow: key.device_code_flow, + primary: key.primary, + } +} + +fn build_model_state(current_model: &str, inventory: &ProviderInventoryEntry) -> SessionModelState { + let mut available_models = inventory + .models + .iter() + .map(|model| ModelInfo::new(ModelId::new(model.id.as_str()), model.name.as_str())) + .collect::>(); + if !available_models + .iter() + .any(|model| model.model_id.0.as_ref() == current_model) + { + available_models.insert( + 0, + ModelInfo::new(ModelId::new(current_model), current_model), + ); + } + SessionModelState::new(ModelId::new(current_model), available_models) +} + +struct ProviderOptionEntry { + id: String, + label: String, } -async fn list_provider_entries(current_provider: Option<&str>) -> Vec { - let mut providers = goose::providers::providers() +async fn list_provider_entries(current_provider: Option<&str>) -> Vec { + let mut providers = crate::providers::providers() .await .into_iter() - .map(|(metadata, _)| ProviderListEntry { + .map(|(metadata, _)| ProviderOptionEntry { id: metadata.name, label: metadata.display_name, }) @@ -435,7 +604,7 @@ async fn list_provider_entries(current_provider: Option<&str>) -> Vec) -> Vec &str { async fn resolve_provider_and_model_from_config( config: &Config, goose_session: &Session, -) -> Result<(String, goose::model::ModelConfig), String> { +) -> Result<(String, crate::model::ModelConfig), String> { let global_provider = config.get_goose_provider().ok(); let provider_override = goose_session .provider_name @@ -487,17 +656,17 @@ async fn resolve_provider_and_model_from_config( let model_config = match &goose_session.model_config { Some(mc) => mc.clone(), None if explicitly_switched => { - let entry = goose::providers::get_from_registry(&provider_name) + let entry = crate::providers::get_from_registry(&provider_name) .await .map_err(|e| e.to_string())?; let default_model = &entry.metadata().default_model; - goose::model::ModelConfig::new(default_model) + crate::model::ModelConfig::new(default_model) .map_err(|e| e.to_string())? .with_canonical_limits(&provider_name) } None => { let model_id = config.get_goose_model().map_err(|e| e.to_string())?; - goose::model::ModelConfig::new(&model_id) + crate::model::ModelConfig::new(&model_id) .map_err(|e| e.to_string())? .with_canonical_limits(&provider_name) } @@ -510,7 +679,7 @@ async fn resolve_provider_and_model_from_config( async fn resolve_provider_and_model( config_dir: &std::path::Path, goose_session: &Session, -) -> Result<(String, goose::model::ModelConfig), String> { +) -> Result<(String, crate::model::ModelConfig), String> { let config = Config::new(config_dir.join(CONFIG_YAML_NAME), "goose").map_err(|e| e.to_string())?; resolve_provider_and_model_from_config(&config, goose_session).await @@ -533,31 +702,25 @@ fn build_mode_state(current_mode: GooseMode) -> Result, +fn should_refresh_inventory_for_session_init(entry: &ProviderInventoryEntry) -> bool { + entry.configured + && entry.supports_refresh + && (entry.last_updated_at.is_none() || ProviderInventoryService::is_stale(entry)) +} + +async fn build_eager_config_from_inventory( + provider_name: &str, + current_model: &str, + inventory: &ProviderInventoryEntry, mode_state: &SessionModeState, goose_session: &Session, -) -> (Option, Option>) { - let Ok((ref provider_name, ref mc)) = resolved else { - return (None, None); - }; - let recommended = goose::providers::canonical::recommended_models_from_registry(provider_name); - let available: Vec = recommended - .iter() - .map(|name| ModelInfo::new(ModelId::new(&**name), &**name)) - .collect(); - let ms = SessionModelState::new(ModelId::new(mc.model_name.as_str()), available); +) -> (SessionModelState, Vec) { + let ms = build_model_state(current_model, inventory); let provider_selection = session_provider_selection(goose_session); - let provider_options = build_provider_options(Some(provider_name.as_str())).await; + let provider_options = build_provider_options(Some(provider_name)).await; let config_options = build_config_options(mode_state, &ms, provider_selection, provider_options); - (Some(ms), Some(config_options)) + (ms, config_options) } fn build_config_options( @@ -603,6 +766,22 @@ fn build_config_options( ] } +fn to_nonnegative_u64(value: Option) -> Option { + value.and_then(|v| u64::try_from(v).ok()) +} + +fn build_prompt_usage(session: &Session) -> Option { + let total = to_nonnegative_u64(session.total_tokens)?; + let input = to_nonnegative_u64(session.input_tokens).unwrap_or(0); + let output = to_nonnegative_u64(session.output_tokens).unwrap_or(0); + Some(Usage::new(total, input, output)) +} + +fn build_usage_update(session: &Session, context_limit: usize) -> UsageUpdate { + let used = session.total_tokens.unwrap_or(0).max(0) as u64; + UsageUpdate::new(used, context_limit as u64) +} + impl GooseAcpAgent { pub fn permission_manager(&self) -> Arc { Arc::clone(&self.permission_manager) @@ -618,10 +797,11 @@ impl GooseAcpAgent { disable_session_naming: bool, ) -> Result { let session_manager = Arc::new(SessionManager::new(data_dir)); - let thread_manager = Arc::new(goose::session::ThreadManager::new( + let thread_manager = Arc::new(crate::session::ThreadManager::new( session_manager.storage().clone(), )); let permission_manager = Arc::new(PermissionManager::new(config_dir.clone())); + let provider_inventory = ProviderInventoryService::new(session_manager.storage().clone()); Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), @@ -635,6 +815,7 @@ impl GooseAcpAgent { permission_manager, goose_mode, disable_session_naming, + provider_inventory, }) } @@ -642,19 +823,142 @@ impl GooseAcpAgent { Config::new(self.config_dir.join(CONFIG_YAML_NAME), "goose").map_err(Into::into) } + fn config(&self) -> Result { + self.load_config().internal_err_ctx("Failed to read config") + } + async fn create_provider( &self, provider_name: &str, - model_config: goose::model::ModelConfig, + model_config: crate::model::ModelConfig, extensions: Vec, ) -> Result> { (self.provider_factory)(provider_name.to_string(), model_config, extensions).await } + async fn prepare_session_init_config( + &self, + resolved: &Result<(String, crate::model::ModelConfig), String>, + mode_state: &SessionModeState, + goose_session: &Session, + ) -> ( + Option, + Option>, + Option>, + ) { + let Ok((provider_name, model_config)) = resolved else { + return (None, None, None); + }; + + let Some(mut inventory) = self + .provider_inventory + .entry_for_provider(provider_name) + .await + .ok() + .flatten() + else { + return (None, None, None); + }; + + let mut prebuilt_provider = None; + if should_refresh_inventory_for_session_init(&inventory) { + match self.load_config() { + Ok(config) => { + let ext_state = EnabledExtensionsState::extensions_or_default( + Some(&goose_session.extension_data), + &config, + ); + match self + .create_provider(provider_name, model_config.clone(), ext_state) + .await + { + Ok(provider) => { + let provider_id = provider_name.clone(); + prebuilt_provider = Some(provider.clone()); + match self + .provider_inventory + .plan_refresh(std::slice::from_ref(&provider_id)) + .await + { + Ok(plan) if plan.started.iter().any(|id| id == &provider_id) => { + match provider.fetch_recommended_models().await { + Ok(models) => { + if let Err(error) = self + .provider_inventory + .store_refreshed_models(&provider_id, &models) + .await + { + warn!( + provider = %provider_id, + error = %error, + "failed to store refreshed provider inventory during session init" + ); + } + } + Err(error) => { + if let Err(store_error) = self + .provider_inventory + .store_refresh_error( + &provider_id, + error.to_string(), + ) + .await + { + warn!( + provider = %provider_id, + error = %store_error, + "failed to store provider inventory refresh error during session init" + ); + } + } + } + } + Ok(_) => {} + Err(error) => warn!( + provider = %provider_id, + error = %error, + "failed to plan provider inventory refresh during session init" + ), + } + + if let Ok(Some(refreshed_inventory)) = self + .provider_inventory + .entry_for_provider(provider_name) + .await + { + inventory = refreshed_inventory; + } + } + Err(error) => warn!( + provider = %provider_name, + error = %error, + "failed to initialize provider during synchronous inventory refresh" + ), + } + } + Err(error) => warn!( + provider = %provider_name, + error = %error, + "failed to load config during synchronous inventory refresh" + ), + } + } + + let (model_state, config_options) = build_eager_config_from_inventory( + provider_name, + model_config.model_name.as_str(), + &inventory, + mode_state, + goose_session, + ) + .await; + (Some(model_state), Some(config_options), prebuilt_provider) + } + fn spawn_agent_setup( &self, cx: &ConnectionTo, - agent_tx: tokio::sync::watch::Sender, String>>>, + agent_tx: tokio::sync::watch::Sender, req: AgentSetupRequest, ) { let AgentSetupRequest { @@ -662,6 +966,7 @@ impl GooseAcpAgent { goose_session, mcp_servers, resolved_provider, + prebuilt_provider, } = req; let goose_mode = goose_session.goose_mode; @@ -686,8 +991,20 @@ impl GooseAcpAgent { tokio::spawn(async move { let t_setup = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, "perf: agent_setup start (background)"); - let result: Result<(), String> = async { + + // Shared config — read once, used by both phases. + let config = match Config::new(config_dir.join(CONFIG_YAML_NAME), "goose") { + Ok(c) => c, + Err(e) => { + let msg = e.to_string(); + error!(error = %msg, "Background agent setup failed (config)"); + let _ = agent_tx.send(Some(Err(msg))); + return; + } + }; + + // ── Phase 1: create agent + init provider (fast, ~55ms) ────── + let phase1: Result, String> = async { let agent = Arc::new(Agent::with_config(AgentConfig::new( session_manager, permission_manager, @@ -697,11 +1014,57 @@ impl GooseAcpAgent { GoosePlatform::GooseCli, ))); - let config_path = config_dir.join(CONFIG_YAML_NAME); - let mut extensions = Config::new(&config_path, "goose") - .ok() - .map(|c| get_enabled_extensions_with_config(&c)) - .unwrap_or_default(); + // Init provider — reuse the pre-resolved name + model when + // available (already computed in on_new_session), otherwise + // fall back to reading config (e.g. load_session path). + let (provider_name, model_config) = match resolved_provider { + Some(resolved) => resolved, + None => resolve_provider_and_model_from_config(&config, &goose_session).await?, + }; + let ext_state = EnabledExtensionsState::extensions_or_default( + Some(&goose_session.extension_data), + &config, + ); + let provider = match prebuilt_provider { + Some(provider) => provider, + None => provider_factory(provider_name.to_string(), model_config, ext_state) + .await + .map_err(|e| e.to_string())?, + }; + agent + .update_provider(provider.clone(), &goose_session.id) + .await + .map_err(|e| e.to_string())?; + + agent + .update_goose_mode(goose_mode, &internal_session_id) + .await + .map_err(|e| e.to_string())?; + + Ok(agent) + } + .await; + + let agent = match phase1 { + Ok(agent) => { + // Signal ProviderReady — unblocks setProvider / update_provider + // while extensions continue loading below. + let _ = + agent_tx.send(Some(Ok(AgentSetupProgress::ProviderReady(agent.clone())))); + debug!(target: "perf", sid = %sid, ms = t_setup.elapsed().as_millis() as u64, "perf: agent_setup provider_ready (signalled)"); + agent + } + Err(e) => { + error!(error = %e, "Background agent setup failed (provider init)"); + debug!(target: "perf", sid = %sid, ms = t_setup.elapsed().as_millis() as u64, "perf: agent_setup failed (provider)"); + let _ = agent_tx.send(Some(Err(e))); + return; + } + }; + + // ── Phase 2: load extensions (slow, may take seconds) ──────── + let phase2: Result<(), String> = async { + let mut extensions = get_enabled_extensions_with_config(&config); extensions.extend(builtins.iter().map(|b| builtin_to_extension_config(b))); let acp_developer = if (client_fs_capabilities.read_text_file @@ -756,128 +1119,69 @@ impl GooseAcpAgent { } let ext_manager = &agent.extension_manager; - let ext_count = extensions.len(); - let t_ext = std::time::Instant::now(); let extension_futures = extensions .into_iter() .map(|ext| { let ext_manager = Arc::clone(ext_manager); let sid_inner = sid_str.clone(); - let sid_log = sid.clone(); async move { let name = ext.name().to_string(); - let t_one = std::time::Instant::now(); - match ext_manager + if let Err(e) = ext_manager .add_extension(ext, None, None, sid_inner.as_deref()) .await { - Ok(_) => debug!( - target: "perf", - sid = %sid_log, - extension = %name, - ms = t_one.elapsed().as_millis() as u64, - "perf: agent_setup extension_loaded" - ), - Err(e) => { - warn!(extension = %name, error = %e, "extension load failed") - } + warn!(extension = %name, error = %e, "extension load failed"); } } }) .collect::>(); futures::future::join_all(extension_futures).await; - debug!( - target: "perf", - sid = %sid, - ms = t_ext.elapsed().as_millis() as u64, - extensions = ext_count, - "perf: agent_setup extensions_total" - ); if let Some((client, config)) = acp_developer { let info = client.get_info().cloned(); agent .extension_manager - .add_client("developer".into(), config, client, info, None) + .add_client("developer".into(), config, client, info, None, None) .await; } - // Init provider — reuse the pre-resolved name + model when - // available (already computed in on_new_session), otherwise - // fall back to reading config (e.g. load_session path). - let t_prov = std::time::Instant::now(); - let config = Config::new(config_dir.join(CONFIG_YAML_NAME), "goose") - .map_err(|e| e.to_string())?; - let (provider_name, model_config) = match resolved_provider { - Some(resolved) => resolved, - None => resolve_provider_and_model_from_config(&config, &goose_session).await?, - }; - let ext_state = EnabledExtensionsState::extensions_or_default( - Some(&goose_session.extension_data), - &config, - ); - let provider = provider_factory(provider_name.to_string(), model_config, ext_state) - .await - .map_err(|e| e.to_string())?; - agent - .update_provider(provider.clone(), &goose_session.id) - .await - .map_err(|e| e.to_string())?; - - agent - .update_goose_mode(goose_mode, &internal_session_id) - .await - .map_err(|e| e.to_string())?; - debug!(target: "perf", sid = %sid, ms = t_prov.elapsed().as_millis() as u64, "perf: agent_setup provider_init"); - - let t_mcp = std::time::Instant::now(); - let mcp_count = mcp_servers.len(); GooseAcpAgent::add_mcp_extensions(&agent, mcp_servers, &internal_session_id) .await .map_err(|e| e.to_string())?; - debug!( - target: "perf", - sid = %sid, - ms = t_mcp.elapsed().as_millis() as u64, - mcp_servers = mcp_count, - "perf: agent_setup mcp_extensions" - ); - - // Apply any working directory that was set while we were loading. - { - let mut locked = sessions.lock().await; - if let Some(session) = locked.get_mut(session_id.0.as_ref()) { - if let Some(dir) = session.pending_working_dir.take() { - agent.extension_manager.update_working_dir(&dir).await; - } - session.agent = AgentHandle::Ready(agent.clone()); - } - } - - let _ = agent_tx.send(Some(Ok(agent))); Ok(()) } .await; - match &result { - Ok(()) => debug!( - target: "perf", - sid = %sid, - ms = t_setup.elapsed().as_millis() as u64, - "perf: agent_setup done" - ), - Err(e) => { - error!(error = %e, "Background agent setup failed"); - debug!( - target: "perf", - sid = %sid, - ms = t_setup.elapsed().as_millis() as u64, - "perf: agent_setup failed" - ); - let _ = agent_tx.send(Some(Err(e.clone()))); + if let Err(e) = &phase2 { + // Extension failures are non-fatal — individual failures are + // already logged as warnings. Log the top-level error but + // don't block the session: the provider is ready and the agent + // is usable. + error!(error = %e, "Background agent setup: extension phase had errors"); + } + + // Promote the handle to Ready and apply any working directory that + // was set while we were loading — regardless of phase-2 outcome, + // since the agent (with its provider) is fully usable. + { + let mut locked = sessions.lock().await; + if let Some(session) = locked.get_mut(session_id.0.as_ref()) { + if let Some(dir) = session.pending_working_dir.take() { + agent.extension_manager.update_working_dir(&dir).await; + } + session.agent = AgentHandle::Ready(agent.clone()); } } + + let _ = agent_tx.send(Some(Ok(AgentSetupProgress::FullyReady(agent)))); + debug!( + target: "perf", + sid = %sid, + ms = t_setup.elapsed().as_millis() as u64, + "perf: agent_setup done{}", + if phase2.is_err() { " (with extension errors)" } else { "" } + ); }); } @@ -885,16 +1189,49 @@ impl GooseAcpAgent { self.sessions.lock().await.contains_key(session_id) } - fn convert_acp_prompt_to_message(&self, prompt: Vec) -> Message { - let mut user_message = Message::user(); - + /// Convert ACP prompt content blocks into a user message. + fn convert_acp_prompt_to_message(prompt: &[ContentBlock]) -> Message { + let mut message = Message::user(); for block in prompt { match block { ContentBlock::Text(text) => { - user_message = user_message.with_text(&text.text); + let annotated = if let Some(ref ann) = text.annotations { + let audience: Vec = ann + .audience + .as_ref() + .map(|roles| { + roles + .iter() + .filter_map(|r| match r { + sacp::schema::Role::Assistant => Some(Role::Assistant), + sacp::schema::Role::User => Some(Role::User), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + let raw = RawTextContent { + text: sanitize_unicode_tags(&text.text), + meta: None, + }; + if audience.is_empty() { + raw.no_annotation() + } else { + raw.no_annotation().with_audience(audience) + } + } else { + // No annotations — regular user text. + let sanitized = sanitize_unicode_tags(&text.text); + RawTextContent { + text: sanitized, + meta: None, + } + .no_annotation() + }; + message = message.with_content(MessageContent::Text(annotated)); } ContentBlock::Image(image) => { - user_message = user_message.with_image(&image.data, &image.mime_type); + message = message.with_image(&image.data, &image.mime_type); } ContentBlock::Resource(resource) => { if let EmbeddedResourceResource::TextResourceContents(text_resource) = @@ -902,19 +1239,18 @@ impl GooseAcpAgent { { let header = format!("--- Resource: {} ---\n", text_resource.uri); let content = format!("{}{}\n---\n", header, text_resource.text); - user_message = user_message.with_text(&content); + message = message.with_text(&content); } } ContentBlock::ResourceLink(link) => { - if let Some(text) = read_resource_link(link) { - user_message = user_message.with_text(text) + if let Some(text) = read_resource_link(link.clone()) { + message = message.with_text(text); } } ContentBlock::Audio(..) | _ => (), } } - - user_message + message } async fn handle_message_content( @@ -976,7 +1312,7 @@ impl GooseAcpAgent { async fn handle_tool_request( &self, - tool_request: &goose::conversation::message::ToolRequest, + tool_request: &crate::conversation::message::ToolRequest, session_id: &SessionId, session: &mut GooseAcpSession, cx: &ConnectionTo, @@ -998,15 +1334,17 @@ impl GooseAcpAgent { .map(|a| serde_json::Value::Object(a.clone())); let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref()); + let mut initial_tool_call = ToolCall::new( + ToolCallId::new(tool_request.id.clone()), + fallback_title.clone(), + ) + .status(ToolCallStatus::Pending); + if let Some(args) = args_value.clone() { + initial_tool_call = initial_tool_call.raw_input(args); + } cx.send_notification(SessionNotification::new( session_id.clone(), - SessionUpdate::ToolCall( - ToolCall::new( - ToolCallId::new(tool_request.id.clone()), - fallback_title.clone(), - ) - .status(ToolCallStatus::Pending), - ), + SessionUpdate::ToolCall(initial_tool_call), ))?; if let Ok(tool_call) = &tool_request.tool_call { @@ -1024,7 +1362,7 @@ impl GooseAcpAgent { .map(|a| { let s = serde_json::to_string(a).unwrap_or_default(); if s.len() > 300 { - format!("{}…", goose::utils::safe_truncate(&s, 300)) + format!("{}…", crate::utils::safe_truncate(&s, 300)) } else { s } @@ -1105,7 +1443,7 @@ impl GooseAcpAgent { async fn handle_tool_response( &self, - tool_response: &goose::conversation::message::ToolResponse, + tool_response: &crate::conversation::message::ToolResponse, session_id: &SessionId, session: &mut GooseAcpSession, cx: &ConnectionTo, @@ -1327,9 +1665,25 @@ impl GooseAcpAgent { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let project_id = args + .meta + .as_ref() + .and_then(|m| m.get("projectId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let persona_id = args + .meta + .as_ref() + .and_then(|m| m.get("personaId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + // Create the Thread — this IS the ACP session from the client's perspective. - let thread_metadata = goose::session::ThreadMetadata { + let thread_metadata = crate::session::ThreadMetadata { provider_id: requested_provider.clone(), + project_id, + persona_id, mode: Some(self.goose_mode.to_string()), ..Default::default() }; @@ -1342,9 +1696,7 @@ impl GooseAcpAgent { Some(args.cwd.display().to_string()), ) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to create thread: {}", e)) - })?; + .internal_err_ctx("Failed to create thread")?; let thread_id = thread.id.clone(); let sid = sid_short(&thread_id); debug!(target: "perf", sid = %sid, ms = t0.elapsed().as_millis() as u64, "perf: new_session create_thread"); @@ -1363,8 +1715,7 @@ impl GooseAcpAgent { let internal_session_id = goose_session.id.clone(); - let (agent_tx, agent_rx) = - tokio::sync::watch::channel::, String>>>(None); + let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); let session = GooseAcpSession { agent: AgentHandle::Loading(agent_rx), @@ -1383,27 +1734,40 @@ impl GooseAcpAgent { // Resolve provider + model from config so we can include the current // model in the response without waiting for the full agent setup. let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; + let initial_usage_update = resolved + .as_ref() + .ok() + .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())); + let session_id = SessionId::new(thread_id.clone()); + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, agent_tx, AgentSetupRequest { - session_id: SessionId::new(thread_id.clone()), + session_id: session_id.clone(), goose_session, mcp_servers: args.mcp_servers, - resolved_provider: resolved.ok(), + resolved_provider: resolved.as_ref().ok().cloned(), + prebuilt_provider, }, ); - let mut response = NewSessionResponse::new(SessionId::new(thread_id)).modes(mode_state); + let mut response = NewSessionResponse::new(session_id.clone()).modes(mode_state); if let Some(ms) = model_state { response = response.models(ms); } if let Some(co) = config_options { response = response.config_options(co); } + if let Some(usage_update) = initial_usage_update { + cx.send_notification(SessionNotification::new( + session_id, + SessionUpdate::UsageUpdate(usage_update), + ))?; + } debug!( target: "perf", sid = %sid, @@ -1431,9 +1795,7 @@ impl GooseAcpAgent { self.goose_mode, ) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to create session: {}", e)) - })?; + .internal_err_ctx("Failed to create session")?; let mut builder = self.session_manager.update(&goose_session.id); builder = builder.thread_id(Some(thread_id.to_string())); @@ -1441,62 +1803,104 @@ impl GooseAcpAgent { builder = builder.provider_name(provider); } if let Some(model) = model_name { - if let Ok(mc) = goose::model::ModelConfig::new(model) { + if let Ok(mc) = crate::model::ModelConfig::new(model) { builder = builder.model_config(mc); } } - builder.apply().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to link session to thread: {}", e)) - })?; + builder + .apply() + .await + .internal_err_ctx("Failed to link session to thread")?; self.session_manager .get_session(&goose_session.id, false) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to reload session: {}", e)) - }) + .internal_err_ctx("Failed to reload session") + } + + /// Look up the session and return the agent if already ready, or the watch + /// receiver if still loading. Optionally sets a cancellation token on the + /// session (needed by `on_prompt`). + async fn get_agent_or_receiver( + &self, + thread_id: &str, + cancel_token: Option, + ) -> Result, tokio::sync::watch::Receiver>, sacp::Error> + { + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(thread_id).ok_or_else(|| { + sacp::Error::resource_not_found(Some(thread_id.to_string())) + .data(format!("Session not found: {}", thread_id)) + })?; + if let Some(token) = cancel_token { + session.cancel_token = Some(token); + } + match &session.agent { + AgentHandle::Ready(agent) => Ok(Either::Left(agent.clone())), + AgentHandle::Loading(rx) => Ok(Either::Right(rx.clone())), + } } + /// Wait until the agent is **fully ready** (provider + all extensions). + /// Most callers (e.g. `on_prompt`, `on_get_tools`) should use this. async fn get_session_agent( &self, thread_id: &str, cancel_token: Option, ) -> Result, sacp::Error> { - let mut rx = { - let mut sessions = self.sessions.lock().await; - let session = sessions.get_mut(thread_id).ok_or_else(|| { - sacp::Error::resource_not_found(Some(thread_id.to_string())) - .data(format!("Session not found: {}", thread_id)) - })?; - if let Some(token) = cancel_token { - session.cancel_token = Some(token); - } - match &session.agent { - AgentHandle::Ready(agent) => return Ok(agent.clone()), - AgentHandle::Loading(rx) => rx.clone(), - } + let mut rx = match self.get_agent_or_receiver(thread_id, cancel_token).await? { + Either::Left(agent) => return Ok(agent), + Either::Right(rx) => rx, }; - // Drop the lock while we wait for the background setup to finish. - // spawn_agent_setup promotes the handle to Ready before signalling. - let agent = { - let guard = rx.wait_for(|v| v.is_some()).await.map_err(|_| { + // Wait specifically for FullyReady (not just ProviderReady). + let guard = rx + .wait_for(|v| { + matches!( + v, + Some(Ok(AgentSetupProgress::FullyReady(_))) | Some(Err(_)) + ) + }) + .await + .map_err(|_| { sacp::Error::internal_error().data("Agent setup task was dropped".to_string()) })?; - guard - .as_ref() - .unwrap() - .as_ref() - .map_err(|e| sacp::Error::internal_error().data(e.clone()))? - .clone() - }; - Ok(agent) + match guard.as_ref().unwrap() { + Ok(AgentSetupProgress::FullyReady(agent)) => Ok(agent.clone()), + Err(e) => Err(sacp::Error::internal_error().data(e.clone())), + // wait_for predicate excludes ProviderReady + _ => unreachable!(), + } } - async fn add_mcp_extensions( - agent: &Arc, - mcp_servers: Vec, - internal_session_id: &str, - ) -> Result<(), sacp::Error> { + /// Wait only until the **provider** is initialized. Extensions may still + /// be loading in the background. Use this for operations that only touch + /// the provider (e.g. `update_provider`, `set_model`, `build_config_update`). + async fn get_session_agent_provider_ready( + &self, + thread_id: &str, + ) -> Result, sacp::Error> { + let mut rx = match self.get_agent_or_receiver(thread_id, None).await? { + Either::Left(agent) => return Ok(agent), + Either::Right(rx) => rx, + }; + // Any signal (ProviderReady, FullyReady, or Err) unblocks us. + let guard = rx.wait_for(|v| v.is_some()).await.map_err(|_| { + sacp::Error::internal_error().data("Agent setup task was dropped".to_string()) + })?; + match guard.as_ref().unwrap() { + Ok(progress) => match progress { + AgentSetupProgress::ProviderReady(agent) + | AgentSetupProgress::FullyReady(agent) => Ok(agent.clone()), + }, + Err(e) => Err(sacp::Error::internal_error().data(e.clone())), + } + } + + async fn add_mcp_extensions( + agent: &Arc, + mcp_servers: Vec, + internal_session_id: &str, + ) -> Result<(), sacp::Error> { let mut configs = Vec::with_capacity(mcp_servers.len()); for mcp_server in mcp_servers { let config = match mcp_server_to_extension_config(mcp_server) { @@ -1515,7 +1919,7 @@ impl GooseAcpAgent { let results = agent .add_extensions_bulk(configs, internal_session_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; for result in &results { if !result.success { let error_msg = result.error.as_deref().unwrap_or("unknown error"); @@ -1539,7 +1943,6 @@ impl GooseAcpAgent { let thread_id = args.session_id.0.to_string(); let sid = sid_short(&thread_id); let t_start = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, "perf: load_session start"); let t0 = std::time::Instant::now(); let thread = self @@ -1564,10 +1967,7 @@ impl GooseAcpAgent { .session_manager .get_session(&internal_session_id, false) .await - .map_err(|e| { - sacp::Error::internal_error() - .data(format!("Failed to load internal session: {}", e)) - })?; + .internal_err_ctx("Failed to load internal session")?; debug!(target: "perf", sid = %sid, ms = t1.elapsed().as_millis() as u64, "perf: load_session get_session"); let loaded_mode = goose_session.goose_mode; @@ -1581,9 +1981,7 @@ impl GooseAcpAgent { .thread_manager .list_messages(&thread_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to load thread messages: {}", e)) - })?; + .internal_err_ctx("Failed to load thread messages")?; debug!( target: "perf", sid = %sid, @@ -1596,10 +1994,8 @@ impl GooseAcpAgent { // so that handle_tool_response can extract file locations from the // matching request. No GooseAcpSession required. let mut replay_tool_requests = - HashMap::::new(); + HashMap::::new(); - let t_replay = std::time::Instant::now(); - let mut replay_notifications: u32 = 0; for message in &thread_messages { if !message.metadata.user_visible { continue; @@ -1608,9 +2004,21 @@ impl GooseAcpAgent { for content_item in &message.content { match content_item { MessageContent::Text(text) => { - let chunk = ContentChunk::new(ContentBlock::Text(TextContent::new( - text.text.clone(), - ))); + let mut tc = TextContent::new(text.text.clone()); + if let Some(audience) = text.audience() { + tc = tc.annotations( + Annotations::new().audience( + audience + .iter() + .map(|r| match r { + Role::Assistant => sacp::schema::Role::Assistant, + Role::User => sacp::schema::Role::User, + }) + .collect::>(), + ), + ); + } + let chunk = ContentChunk::new(ContentBlock::Text(tc)); let update = match message.role { Role::User => SessionUpdate::UserMessageChunk(chunk), Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), @@ -1619,7 +2027,6 @@ impl GooseAcpAgent { args.session_id.clone(), update, ))?; - replay_notifications += 1; } MessageContent::ToolRequest(tool_request) => { // Replay-only: emit the ToolCall notification and @@ -1642,7 +2049,6 @@ impl GooseAcpAgent { .status(ToolCallStatus::Pending), ), ))?; - replay_notifications += 1; } MessageContent::ToolResponse(tool_response) => { // Replay-only: emit the ToolCallUpdate notification, @@ -1685,7 +2091,6 @@ impl GooseAcpAgent { fields, )), ))?; - replay_notifications += 1; } MessageContent::Thinking(thinking) => { cx.send_notification(SessionNotification::new( @@ -1694,44 +2099,27 @@ impl GooseAcpAgent { ContentBlock::Text(TextContent::new(thinking.thinking.clone())), )), ))?; - replay_notifications += 1; } _ => {} } } } - debug!( - target: "perf", - sid = %sid, - ms = t_replay.elapsed().as_millis() as u64, - notifications = replay_notifications, - "perf: load_session replay_loop" - ); // ── Lightweight DB updates (fast) ── - let t_db = std::time::Instant::now(); self.session_manager .update(&internal_session_id) .working_dir(args.cwd.clone()) .apply() .await - .map_err(|e| { - sacp::Error::internal_error() - .data(format!("Failed to update session working directory: {}", e)) - })?; + .internal_err_ctx("Failed to update session working directory")?; self.thread_manager .update_working_dir(&thread_id, &args.cwd.display().to_string()) .await - .map_err(|e| { - sacp::Error::internal_error() - .data(format!("Failed to update thread working directory: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_db.elapsed().as_millis() as u64, "perf: load_session db_updates"); + .internal_err_ctx("Failed to update thread working directory")?; // ── Register the session immediately with a Loading handle ── - let (agent_tx, agent_rx) = - tokio::sync::watch::channel::, String>>>(None); + let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); let session = GooseAcpSession { agent: AgentHandle::Loading(agent_rx), @@ -1748,8 +2136,19 @@ impl GooseAcpAgent { let mode_state = build_mode_state(loaded_mode)?; let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; + let initial_usage_update = resolved + .as_ref() + .ok() + .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())) + .or_else(|| { + goose_session + .model_config + .as_ref() + .map(|mc| build_usage_update(&goose_session, mc.context_limit())) + }); + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, @@ -1759,6 +2158,7 @@ impl GooseAcpAgent { goose_session, mcp_servers: args.mcp_servers, resolved_provider: None, + prebuilt_provider, }, ); @@ -1769,6 +2169,12 @@ impl GooseAcpAgent { if let Some(co) = config_options { response = response.config_options(co); } + if let Some(usage_update) = initial_usage_update { + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::UsageUpdate(usage_update), + ))?; + } debug!( target: "perf", sid = %sid, @@ -1787,27 +2193,36 @@ impl GooseAcpAgent { let thread_id = args.session_id.0.to_string(); let sid = sid_short(&thread_id); let t_start = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, "perf: prompt start"); + + // Update persona_id on the thread if the client sent one in _meta. + let prompt_persona_id = args + .meta + .as_ref() + .and_then(|m| m.get("personaId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(ref pid) = prompt_persona_id { + let pid = pid.clone(); + self.update_thread_metadata(&thread_id, move |meta| { + meta.persona_id = Some(pid); + }) + .await?; + } let cancel_token = CancellationToken::new(); let internal_session_id = self.internal_session_id(&thread_id).await?; - let t_agent = std::time::Instant::now(); let agent = self .get_session_agent(&thread_id, Some(cancel_token.clone())) .await?; - debug!(target: "perf", sid = %sid, ms = t_agent.elapsed().as_millis() as u64, "perf: prompt get_session_agent (waits for agent setup)"); - let user_message = self.convert_acp_prompt_to_message(args.prompt); + let user_message = Self::convert_acp_prompt_to_message(&args.prompt); - let t_persist = std::time::Instant::now(); + // Persist user message (may contain assistant-only annotated blocks) self.thread_manager .append_message(&thread_id, Some(&internal_session_id), &user_message) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to persist message: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_persist.elapsed().as_millis() as u64, "perf: prompt append_user_message"); + .internal_err_ctx("Failed to persist message")?; let session_config = SessionConfig { id: internal_session_id.clone(), @@ -1816,14 +2231,10 @@ impl GooseAcpAgent { retry_config: None, }; - let t_reply = std::time::Instant::now(); let mut stream = agent .reply(user_message, session_config, Some(cancel_token.clone())) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Error getting agent reply: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_reply.elapsed().as_millis() as u64, "perf: prompt agent.reply() setup"); + .internal_err_ctx("Error getting agent reply")?; use futures::StreamExt; @@ -1848,14 +2259,11 @@ impl GooseAcpAgent { } match event { - Ok(goose::agents::AgentEvent::Message(message)) => { + Ok(crate::agents::AgentEvent::Message(message)) => { self.thread_manager .append_message(&thread_id, Some(&internal_session_id), &message) .await - .map_err(|e| { - sacp::Error::internal_error() - .data(format!("Failed to persist message: {}", e)) - })?; + .internal_err_ctx("Failed to persist message")?; let mut sessions = self.sessions.lock().await; let session = sessions.get_mut(&thread_id).ok_or_else(|| { @@ -1882,10 +2290,29 @@ impl GooseAcpAgent { } } - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(&thread_id) { - session.cancel_token = None; + { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(&thread_id) { + session.cancel_token = None; + } } + + let session = self + .session_manager + .get_session(&internal_session_id, false) + .await + .internal_err_ctx("Failed to load session")?; + let provider = agent + .provider() + .await + .internal_err_ctx("Failed to get provider")?; + let usage_update = + build_usage_update(&session, provider.get_model_config().context_limit()); + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::UsageUpdate(usage_update), + ))?; + debug!( target: "perf", sid = %sid, @@ -1894,11 +2321,17 @@ impl GooseAcpAgent { cancelled = was_cancelled, "perf: prompt done" ); - Ok(PromptResponse::new(if was_cancelled { + let stop_reason = if was_cancelled { StopReason::Cancelled } else { StopReason::EndTurn - })) + }; + + let mut response = PromptResponse::new(stop_reason); + if let Some(usage) = build_prompt_usage(&session) { + response = response.usage(usage); + } + Ok(response) } async fn on_cancel(&self, args: CancelNotification) -> Result<(), sacp::Error> { @@ -1924,75 +2357,37 @@ impl GooseAcpAgent { thread_id: &str, model_id: &str, ) -> Result { - let sid = sid_short(thread_id); - let t_total = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, model = %model_id, "perf: set_model start"); - - let t_step = std::time::Instant::now(); let internal_id = self.internal_session_id(thread_id).await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model internal_session_id"); - - let t_step = std::time::Instant::now(); - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model load_config"); - - let t_step = std::time::Instant::now(); - let agent = self.get_session_agent(thread_id, None).await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model get_session_agent (waits for agent setup)"); - - let t_step = std::time::Instant::now(); - let current_provider = agent.provider().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) - })?; + let config = self.config()?; + let agent = self.get_session_agent_provider_ready(thread_id).await?; + let current_provider = agent + .provider() + .await + .internal_err_ctx("Failed to get provider")?; let provider_name = current_provider.get_name().to_string(); let extensions = EnabledExtensionsState::for_session(&self.session_manager, &internal_id, &config).await; - let model_config = goose::model::ModelConfig::new(model_id) - .map_err(|e| { - sacp::Error::invalid_params().data(format!("Invalid model config: {}", e)) - })? + let model_config = crate::model::ModelConfig::new(model_id) + .invalid_params_err_ctx("Invalid model config")? .with_canonical_limits(&provider_name); - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, provider = %provider_name, "perf: set_model build_model_config"); - - let t_step = std::time::Instant::now(); let provider = self .create_provider(&provider_name, model_config, extensions) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to create provider: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, provider = %provider_name, "perf: set_model create_provider"); - - let t_step = std::time::Instant::now(); + .internal_err_ctx("Failed to create provider")?; agent .update_provider(provider, &internal_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to update provider: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model agent.update_provider"); - - let t_step = std::time::Instant::now(); + .internal_err_ctx("Failed to update provider")?; let mode = agent.goose_mode().await; agent .update_goose_mode(mode, &internal_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to propagate mode: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model update_goose_mode"); - - let t_step = std::time::Instant::now(); + .internal_err_ctx("Failed to propagate mode")?; let model_id_owned = model_id.to_string(); self.update_thread_metadata(thread_id, move |meta| { - meta.model_name = Some(model_id_owned); + meta.model_id = Some(model_id_owned); }) .await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model update_thread_metadata"); - - debug!(target: "perf", sid = %sid, ms = t_total.elapsed().as_millis() as u64, model = %model_id, "perf: set_model done"); Ok(SetSessionModelResponse::new()) } @@ -2011,12 +2406,12 @@ impl GooseAcpAgent { async fn update_thread_metadata( &self, thread_id: &str, - f: impl FnOnce(&mut goose::session::ThreadMetadata), + f: impl FnOnce(&mut crate::session::ThreadMetadata), ) -> Result<(), sacp::Error> { self.thread_manager .update_metadata(thread_id, f) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(()) } @@ -2029,15 +2424,27 @@ impl GooseAcpAgent { .session_manager .get_session(&internal_id, false) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let agent = self.get_session_agent(&thread_id.0, None).await?; - let provider = agent.provider().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) - })?; + .internal_err()?; + let agent = self.get_session_agent_provider_ready(&thread_id.0).await?; + let provider = agent + .provider() + .await + .internal_err_ctx("Failed to get provider")?; + let provider_name = provider.get_name().to_string(); + let current_model = provider.get_model_config().model_name.clone(); let goose_mode = agent.goose_mode().await; - let model_state = build_model_state(&*provider).await?; + let inventory = self + .provider_inventory + .entry_for_provider(&provider_name) + .await + .internal_err()?; + let Some(inventory) = inventory else { + return Err(sacp::Error::internal_error() + .data(format!("Unknown provider inventory: {}", provider_name))); + }; + let model_state = build_model_state(current_model.as_str(), &inventory); let mode_state = build_mode_state(goose_mode)?; - let provider_options = build_provider_options(Some(provider.get_name())).await; + let provider_options = build_provider_options(Some(&provider_name)).await; let config_options = build_config_options( &mode_state, &model_state, @@ -2061,13 +2468,11 @@ impl GooseAcpAgent { sacp::Error::invalid_params().data(format!("Invalid mode: {}", mode_id)) })?; - let agent = self.get_session_agent(thread_id, None).await?; + let agent = self.get_session_agent_provider_ready(thread_id).await?; agent .update_goose_mode(mode, &internal_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to update mode: {}", e)) - })?; + .internal_err_ctx("Failed to update mode")?; let mode_id = mode_id.to_string(); self.update_thread_metadata(thread_id, move |meta| { @@ -2086,40 +2491,22 @@ impl GooseAcpAgent { context_limit: Option, request_params: Option>, ) -> Result<(), sacp::Error> { - let sid = sid_short(thread_id); - let t_total = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, provider = %provider_name, "perf: update_provider start"); - - let t_step = std::time::Instant::now(); let internal_id = self.internal_session_id(thread_id).await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider internal_session_id"); - - let t_step = std::time::Instant::now(); - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider load_config"); - - let t_step = std::time::Instant::now(); - let agent = self.get_session_agent(thread_id, None).await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider get_session_agent (waits for agent setup)"); - - let t_step = std::time::Instant::now(); - let current_provider = agent.provider().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) - })?; + let config = self.config()?; + let agent = self.get_session_agent_provider_ready(thread_id).await?; + let current_provider = agent + .provider() + .await + .internal_err_ctx("Failed to get provider")?; let current_provider_name = current_provider.get_name(); let current_model = current_provider.get_model_config().model_name; let has_default_overrides = model_name.is_some() || context_limit.is_some() || request_params.is_some(); let use_default_provider = provider_name == DEFAULT_PROVIDER_ID; let resolved_provider_name = if use_default_provider { - config.get_goose_provider().map_err(|e| { - sacp::Error::internal_error().data(format!( - "Failed to resolve default provider from config: {}", - e - )) - })? + config + .get_goose_provider() + .internal_err_ctx("Failed to resolve default provider from config")? } else { provider_name.to_string() }; @@ -2127,125 +2514,67 @@ impl GooseAcpAgent { let default_model = if let Some(model_name) = model_name { model_name.to_string() } else if use_default_provider { - config.get_goose_model().map_err(|e| { - sacp::Error::internal_error().data(format!( - "Failed to resolve default model from config: {}", - e - )) - })? + config + .get_goose_model() + .internal_err_ctx("Failed to resolve default model from config")? } else if is_changing_provider { ACP_CURRENT_MODEL.to_string() } else { current_model }; let model = model_name.unwrap_or(&default_model); - let model_config = goose::model::ModelConfig::new(model) - .map_err(|e| { - sacp::Error::invalid_params().data(format!("Invalid model config: {}", e)) - })? + let model_config = crate::model::ModelConfig::new(model) + .invalid_params_err_ctx("Invalid model config")? .with_canonical_limits(&resolved_provider_name) .with_context_limit(context_limit) .with_request_params(request_params); - debug!( - target: "perf", - sid = %sid, - ms = t_step.elapsed().as_millis() as u64, - resolved_provider = %resolved_provider_name, - current_provider = %current_provider_name, - changing = is_changing_provider, - has_overrides = has_default_overrides, - "perf: update_provider resolve_defaults" - ); - let t_step = std::time::Instant::now(); let extensions = EnabledExtensionsState::for_session(&self.session_manager, &internal_id, &config).await; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider build_extensions"); - - let t_step = std::time::Instant::now(); let new_provider = self .create_provider(&resolved_provider_name, model_config, extensions) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to create provider: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, provider = %resolved_provider_name, "perf: update_provider create_provider"); - - let t_step = std::time::Instant::now(); + .internal_err_ctx("Failed to create provider")?; agent .update_provider(new_provider, &internal_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to update provider: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider agent.update_provider"); - - let t_step = std::time::Instant::now(); + .internal_err_ctx("Failed to update provider")?; let mode = agent.goose_mode().await; agent .update_goose_mode(mode, &internal_id) .await - .map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to propagate mode: {}", e)) - })?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider update_goose_mode"); - - let provider = agent.provider().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) - })?; + .internal_err_ctx("Failed to propagate mode")?; + let provider = agent + .provider() + .await + .internal_err_ctx("Failed to get provider")?; - let t_step = std::time::Instant::now(); let provider_name_owned = provider_name.to_string(); self.update_thread_metadata(thread_id, move |meta| { meta.provider_id = Some(provider_name_owned); + meta.model_id = None; }) .await?; - debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider update_thread_metadata"); - let t_step = std::time::Instant::now(); if use_default_provider { let update = self .session_manager .update(&internal_id) .provider_name(DEFAULT_PROVIDER_ID); if has_default_overrides { - let provider_model_config = provider.get_model_config(); update - .model_config(provider_model_config) + .model_config(provider.get_model_config()) .apply() .await - .map_err(|e| { - sacp::Error::internal_error().data(format!( - "Failed to persist default provider selection overrides: {}", - e - )) - })?; + .internal_err_ctx("Failed to persist default provider selection overrides")?; } else { - update.clear_model_config().apply().await.map_err(|e| { - sacp::Error::internal_error().data(format!( - "Failed to persist default provider selection: {}", - e - )) - })?; + update + .clear_model_config() + .apply() + .await + .internal_err_ctx("Failed to persist default provider selection")?; } } - debug!( - target: "perf", - sid = %sid, - ms = t_step.elapsed().as_millis() as u64, - persisted = use_default_provider, - "perf: update_provider persist_session" - ); - - debug!( - target: "perf", - sid = %sid, - ms = t_total.elapsed().as_millis() as u64, - provider = %provider_name, - resolved_provider = %resolved_provider_name, - changing = is_changing_provider, - "perf: update_provider done" - ); Ok(()) } @@ -2255,7 +2584,7 @@ impl GooseAcpAgent { .thread_manager .list_threads(false) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; let session_infos: Vec = threads .into_iter() .map(|t| { @@ -2264,11 +2593,7 @@ impl GooseAcpAgent { .as_deref() .map(std::path::PathBuf::from) .unwrap_or_default(); - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(t.message_count.into()), - ); + let meta = thread_session_meta(&t); SessionInfo::new(SessionId::new(t.id), cwd) .title(t.name) .updated_at(t.updated_at.to_rfc3339()) @@ -2290,7 +2615,7 @@ impl GooseAcpAgent { .thread_manager .fork_thread(source_thread_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; let new_thread_id = new_thread.id.clone(); // Create an internal session for the new thread. @@ -2300,8 +2625,7 @@ impl GooseAcpAgent { let internal_session_id = goose_session.id.clone(); - let (agent_tx, agent_rx) = - tokio::sync::watch::channel::, String>>>(None); + let (agent_tx, agent_rx) = tokio::sync::watch::channel::(None); let session = GooseAcpSession { agent: AgentHandle::Loading(agent_rx), @@ -2317,8 +2641,9 @@ impl GooseAcpAgent { let mode_state = build_mode_state(self.goose_mode)?; let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, @@ -2328,14 +2653,11 @@ impl GooseAcpAgent { goose_session, mcp_servers: args.mcp_servers, resolved_provider: resolved.ok(), + prebuilt_provider, }, ); - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(new_thread.message_count.into()), - ); + let meta = thread_session_meta(&new_thread); let mut response = ForkSessionResponse::new(SessionId::new(new_thread_id)) .modes(mode_state) @@ -2377,7 +2699,7 @@ impl GooseAcpAgent { agent .add_extension(config, &internal_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(EmptyResponse {}) } @@ -2391,7 +2713,7 @@ impl GooseAcpAgent { agent .remove_extension(&req.name, &internal_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(EmptyResponse {}) } @@ -2404,7 +2726,7 @@ impl GooseAcpAgent { .into_iter() .map(|t| serde_json::to_value(&t)) .collect::, _>>() - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(GetToolsResponse { tools: tools_json }) } @@ -2420,9 +2742,8 @@ impl GooseAcpAgent { .extension_manager .read_resource(&internal_id, &req.uri, &req.extension_name, cancel_token) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let result_json = serde_json::to_value(&result) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; + let result_json = serde_json::to_value(&result).internal_err()?; Ok(ReadResourceResponse { result: result_json, }) @@ -2447,12 +2768,12 @@ impl GooseAcpAgent { .working_dir(path.clone()) .apply() .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; self.thread_manager .update_working_dir(&req.session_id, &working_dir) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; if let Some(session) = self.sessions.lock().await.get_mut(&req.session_id) { match &session.agent { @@ -2477,26 +2798,93 @@ impl GooseAcpAgent { self.thread_manager .delete_thread(&req.session_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; self.sessions.lock().await.remove(&req.session_id); Ok(EmptyResponse {}) } #[custom_method(GetExtensionsRequest)] async fn on_get_extensions(&self) -> Result { - let extensions = goose::config::extensions::get_all_extensions(); - let warnings = goose::config::extensions::get_warnings(); + let extensions = crate::config::extensions::get_all_extensions(); + let warnings = crate::config::extensions::get_warnings(); let extensions_json = extensions .into_iter() - .map(|e| serde_json::to_value(&e)) + .map(|e| { + let config_key = e.config.key(); + let mut value = serde_json::to_value(&e)?; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "config_key".to_string(), + serde_json::Value::String(config_key), + ); + } + Ok::<_, serde_json::Error>(value) + }) .collect::, _>>() - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(GetExtensionsResponse { extensions: extensions_json, warnings, }) } + #[custom_method(AddConfigExtensionRequest)] + async fn on_add_config_extension( + &self, + req: AddConfigExtensionRequest, + ) -> Result { + let mut obj = match req.extension_config { + serde_json::Value::Object(obj) => obj, + _ => { + return Err( + sacp::Error::invalid_params().data("extensionConfig must be a JSON object") + ); + } + }; + obj.insert( + "name".to_string(), + serde_json::Value::String(req.name.clone()), + ); + + let config: crate::agents::ExtensionConfig = + serde_json::from_value(serde_json::Value::Object(obj)) + .map_err(|e| sacp::Error::invalid_params().data(format!("bad config: {e}")))?; + + crate::config::extensions::set_extension(crate::config::extensions::ExtensionEntry { + enabled: req.enabled, + config, + }); + Ok(EmptyResponse {}) + } + + #[custom_method(RemoveConfigExtensionRequest)] + async fn on_remove_config_extension( + &self, + req: RemoveConfigExtensionRequest, + ) -> Result { + let keys = crate::config::extensions::get_all_extension_names(); + if !keys.iter().any(|k| k == &req.config_key) { + return Err(sacp::Error::invalid_params() + .data(format!("Extension '{}' not found", req.config_key))); + } + crate::config::extensions::remove_extension(&req.config_key); + Ok(EmptyResponse {}) + } + + #[custom_method(ToggleConfigExtensionRequest)] + async fn on_toggle_config_extension( + &self, + req: ToggleConfigExtensionRequest, + ) -> Result { + let keys = crate::config::extensions::get_all_extension_names(); + if !keys.iter().any(|k| k == &req.config_key) { + return Err(sacp::Error::invalid_params() + .data(format!("Extension '{}' not found", req.config_key))); + } + crate::config::extensions::set_extension_enabled(&req.config_key, req.enabled); + Ok(EmptyResponse {}) + } + #[custom_method(GetSessionExtensionsRequest)] async fn on_get_session_extensions( &self, @@ -2507,18 +2895,18 @@ impl GooseAcpAgent { .session_manager .get_session(&internal_id, false) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; let extensions = EnabledExtensionsState::extensions_or_default( Some(&session.extension_data), - goose::config::Config::global(), + crate::config::Config::global(), ); let extensions_json = extensions .into_iter() .map(|e| serde_json::to_value(&e)) .collect::, _>>() - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(GetSessionExtensionsResponse { extensions: extensions_json, @@ -2528,125 +2916,78 @@ impl GooseAcpAgent { #[custom_method(ListProvidersRequest)] async fn on_list_providers( &self, - _req: ListProvidersRequest, + req: ListProvidersRequest, ) -> Result { + let entries = self + .provider_inventory + .entries(&req.provider_ids) + .await + .internal_err()?; Ok(ListProvidersResponse { - providers: list_provider_entries(None).await, + entries: entries.into_iter().map(inventory_entry_to_dto).collect(), }) } - #[custom_method(GetProviderDetailsRequest)] - async fn on_get_provider_details( + #[custom_method(RefreshProviderInventoryRequest)] + async fn on_refresh_provider_inventory( &self, - _req: GetProviderDetailsRequest, - ) -> Result { - let config = self.load_config().ok(); - let all = goose::providers::providers().await; - let entries = all - .into_iter() - .map(|(metadata, provider_type)| { - let is_configured = config - .as_ref() - .map(|c| { - metadata.config_keys.iter().all(|k| { - if !k.required { - return true; - } - if k.secret { - c.get_secret::(&k.name).is_ok() - } else { - c.get_param::(&k.name).is_ok() - } - }) - }) - .unwrap_or(false); - ProviderDetailEntry { - name: metadata.name.clone(), - display_name: metadata.display_name.clone(), - description: metadata.description.clone(), - default_model: metadata.default_model.clone(), - is_configured, - provider_type: format!("{:?}", provider_type), - config_keys: metadata - .config_keys - .iter() - .map(|k| ProviderConfigKey { - name: k.name.clone(), - required: k.required, - secret: k.secret, - default: k.default.clone(), - oauth_flow: k.oauth_flow, - device_code_flow: k.device_code_flow, - primary: k.primary, - }) - .collect(), - setup_steps: metadata.setup_steps.clone(), - known_models: metadata - .known_models - .iter() - .map(|m| ModelEntry { - name: m.name.clone(), - context_limit: m.context_limit, - }) - .collect(), + req: RefreshProviderInventoryRequest, + ) -> Result { + let refresh_plan = self + .provider_inventory + .plan_refresh(&req.provider_ids) + .await; + let refresh_plan = refresh_plan.internal_err()?; + for provider_id in &refresh_plan.started { + let provider_inventory = self.provider_inventory.clone(); + let provider_factory = Arc::clone(&self.provider_factory); + let provider_id = provider_id.clone(); + tokio::spawn(async move { + let result = async { + let metadata = crate::providers::get_from_registry(&provider_id).await?; + let model_config = + crate::model::ModelConfig::new(&metadata.metadata().default_model)? + .with_canonical_limits(&provider_id); + let provider = + provider_factory(provider_id.clone(), model_config, Vec::new()).await?; + let models = provider.fetch_recommended_models().await?; + provider_inventory + .store_refreshed_models(&provider_id, &models) + .await } - }) - .collect(); - Ok(GetProviderDetailsResponse { providers: entries }) - } - - #[custom_method(GetProviderModelsRequest)] - async fn on_get_provider_models( - &self, - req: GetProviderModelsRequest, - ) -> Result { - let config = self.load_config().ok(); - let all = goose::providers::providers().await; - - let Some((metadata, _provider_type)) = - all.into_iter().find(|(m, _)| m.name == req.provider_name) - else { - return Err(sacp::Error::invalid_params() - .data(format!("Unknown provider: {}", req.provider_name))); - }; - - let is_configured = config - .as_ref() - .map(|c| { - metadata.config_keys.iter().all(|k| { - if !k.required { - return true; - } - if k.secret { - c.get_secret::(&k.name).is_ok() - } else { - c.get_param::(&k.name).is_ok() - } - }) - }) - .unwrap_or(false); - - if !is_configured { - return Err(sacp::Error::invalid_params().data(format!( - "Provider '{}' is not configured", - req.provider_name - ))); + .await; + if let Err(error) = result { + let _ = provider_inventory + .store_refresh_error(&provider_id, error.to_string()) + .await; + warn!(provider = %provider_id, error = %error, "provider inventory refresh failed"); + } + }); } - - let model_config = goose::model::ModelConfig::new(&metadata.default_model) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))? - .with_canonical_limits(&req.provider_name); - - let provider = (self.provider_factory)(req.provider_name.clone(), model_config, Vec::new()) - .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - - let models = provider - .fetch_recommended_models() - .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - - Ok(GetProviderModelsResponse { models }) + Ok(RefreshProviderInventoryResponse { + started: refresh_plan.started, + skipped: refresh_plan + .skipped + .into_iter() + .map(|entry| RefreshProviderInventorySkipDto { + provider_id: entry.provider_id, + reason: match entry.reason { + RefreshSkipReason::UnknownProvider => { + RefreshProviderInventorySkipReasonDto::UnknownProvider + } + RefreshSkipReason::NotConfigured => { + RefreshProviderInventorySkipReasonDto::NotConfigured + } + RefreshSkipReason::DoesNotSupportRefresh => { + RefreshProviderInventorySkipReasonDto::DoesNotSupportRefresh + } + RefreshSkipReason::AlreadyRefreshing => { + RefreshProviderInventorySkipReasonDto::AlreadyRefreshing + } + }, + }) + .collect(), + }) } #[custom_method(ReadConfigRequest)] @@ -2654,12 +2995,10 @@ impl GooseAcpAgent { &self, req: ReadConfigRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; + let config = self.config()?; let response = match config.get_param::(&req.key) { Ok(value) => ReadConfigResponse { value }, - Err(goose::config::ConfigError::NotFound(_)) => ReadConfigResponse { + Err(crate::config::ConfigError::NotFound(_)) => ReadConfigResponse { value: serde_json::Value::Null, }, Err(e) => return Err(sacp::Error::internal_error().data(e.to_string())), @@ -2672,12 +3011,8 @@ impl GooseAcpAgent { &self, req: UpsertConfigRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - config - .set_param(&req.key, &req.value) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + let config = self.config()?; + config.set_param(&req.key, &req.value).internal_err()?; Ok(EmptyResponse {}) } @@ -2686,12 +3021,8 @@ impl GooseAcpAgent { &self, req: RemoveConfigRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - config - .delete(&req.key) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + let config = self.config()?; + config.delete(&req.key).internal_err()?; Ok(EmptyResponse {}) } @@ -2700,9 +3031,7 @@ impl GooseAcpAgent { &self, req: CheckSecretRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; + let config = self.config()?; let exists = config.get_secret::(&req.key).is_ok(); Ok(CheckSecretResponse { exists }) } @@ -2712,12 +3041,8 @@ impl GooseAcpAgent { &self, req: UpsertSecretRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - config - .set_secret(&req.key, &req.value) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + let config = self.config()?; + config.set_secret(&req.key, &req.value).internal_err()?; Ok(EmptyResponse {}) } @@ -2726,12 +3051,8 @@ impl GooseAcpAgent { &self, req: RemoveSecretRequest, ) -> Result { - let config = self.load_config().map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to read config: {}", e)) - })?; - config - .delete_secret(&req.key) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + let config = self.config()?; + config.delete_secret(&req.key).internal_err()?; Ok(EmptyResponse {}) } @@ -2744,7 +3065,7 @@ impl GooseAcpAgent { .thread_manager .get_thread(&req.session_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; let internal_id = thread .current_session_id .ok_or_else(|| sacp::Error::internal_error().data("Thread has no internal session"))?; @@ -2752,7 +3073,7 @@ impl GooseAcpAgent { .session_manager .export_session(&internal_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(ExportSessionResponse { data }) } @@ -2765,7 +3086,7 @@ impl GooseAcpAgent { .session_manager .import_session(&req.data, Some(SessionType::Acp)) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; // Create a thread for the imported session. let thread = self @@ -2776,7 +3097,7 @@ impl GooseAcpAgent { Some(session.working_dir.display().to_string()), ) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; // Link the internal session to the thread. self.session_manager @@ -2784,7 +3105,7 @@ impl GooseAcpAgent { .thread_id(Some(thread.id.clone())) .apply() .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; // Copy conversation messages into thread_messages so they appear in the thread. if let Some(ref conversation) = session.conversation { @@ -2792,7 +3113,7 @@ impl GooseAcpAgent { self.thread_manager .append_message(&thread.id, Some(&session.id), msg) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; } } @@ -2801,7 +3122,7 @@ impl GooseAcpAgent { .thread_manager .get_thread(&thread.id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; Ok(ImportSessionResponse { session_id: thread.id, @@ -2811,6 +3132,31 @@ impl GooseAcpAgent { }) } + #[custom_method(UpdateSessionProjectRequest)] + async fn on_update_session_project( + &self, + req: UpdateSessionProjectRequest, + ) -> Result { + let project_id = req.project_id; + self.update_thread_metadata(&req.session_id, move |meta| { + meta.project_id = project_id; + }) + .await?; + Ok(EmptyResponse {}) + } + + #[custom_method(RenameSessionRequest)] + async fn on_rename_session( + &self, + req: RenameSessionRequest, + ) -> Result { + self.thread_manager + .update_thread(&req.session_id, Some(req.title), Some(true), None) + .await + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + Ok(EmptyResponse {}) + } + #[custom_method(ArchiveSessionRequest)] async fn on_archive_session( &self, @@ -2819,7 +3165,7 @@ impl GooseAcpAgent { self.thread_manager .archive_thread(&req.session_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; self.sessions.lock().await.remove(&req.session_id); Ok(EmptyResponse {}) } @@ -2832,9 +3178,475 @@ impl GooseAcpAgent { self.thread_manager .unarchive_thread(&req.session_id) .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + .internal_err()?; + Ok(EmptyResponse {}) + } + + #[custom_method(CreateSourceRequest)] + async fn on_create_source( + &self, + req: CreateSourceRequest, + ) -> Result { + let source = crate::sources::create_source( + req.source_type, + &req.name, + &req.description, + &req.content, + req.global, + req.project_dir.as_deref(), + )?; + Ok(CreateSourceResponse { source }) + } + + #[custom_method(ListSourcesRequest)] + async fn on_list_sources( + &self, + req: ListSourcesRequest, + ) -> Result { + let sources = crate::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + Ok(ListSourcesResponse { sources }) + } + + #[custom_method(UpdateSourceRequest)] + async fn on_update_source( + &self, + req: UpdateSourceRequest, + ) -> Result { + let source = crate::sources::update_source( + req.source_type, + &req.path, + &req.name, + &req.description, + &req.content, + )?; + Ok(UpdateSourceResponse { source }) + } + + #[custom_method(DeleteSourceRequest)] + async fn on_delete_source( + &self, + req: DeleteSourceRequest, + ) -> Result { + crate::sources::delete_source(req.source_type, &req.path)?; Ok(EmptyResponse {}) } + + #[custom_method(ExportSourceRequest)] + async fn on_export_source( + &self, + req: ExportSourceRequest, + ) -> Result { + let (json, filename) = crate::sources::export_source(req.source_type, &req.path)?; + Ok(ExportSourceResponse { json, filename }) + } + + #[custom_method(ImportSourcesRequest)] + async fn on_import_sources( + &self, + req: ImportSourcesRequest, + ) -> Result { + let sources = + crate::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + Ok(ImportSourcesResponse { sources }) + } + + #[custom_method(DictationTranscribeRequest)] + async fn on_dictation_transcribe( + &self, + req: DictationTranscribeRequest, + ) -> Result { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + let config = crate::config::Config::global(); + + #[cfg(not(feature = "local-inference"))] + if req.provider == "local" { + return Err(sacp::Error::invalid_params() + .data("Local inference is not available in this build")); + } + + let provider: DictationProvider = serde_json::from_value(serde_json::Value::String( + req.provider.clone(), + )) + .map_err(|_| { + sacp::Error::invalid_params().data(format!("Unknown provider: {}", req.provider)) + })?; + + let audio_bytes = BASE64 + .decode(&req.audio) + .map_err(|_| sacp::Error::invalid_params().data("Invalid base64 audio data"))?; + + if audio_bytes.len() > 50 * 1024 * 1024 { + return Err(sacp::Error::invalid_params().data("Audio too large (max 50MB)")); + } + + let extension = match req.mime_type.as_str() { + "audio/webm" | "audio/webm;codecs=opus" => "webm", + "audio/mp4" => "mp4", + "audio/mpeg" | "audio/mpga" => "mp3", + "audio/m4a" => "m4a", + "audio/wav" | "audio/x-wav" => "wav", + other => { + return Err( + sacp::Error::invalid_params().data(format!("Unsupported format: {other}")) + ); + } + }; + + let text = match provider { + #[cfg(feature = "local-inference")] + DictationProvider::Local => transcribe_local(audio_bytes).await, + remote => { + let (model_param, default_model) = dictation_transcribe_params(remote); + let model = dictation_selected_model(config, remote) + .unwrap_or_else(|| default_model.to_string()); + transcribe_with_provider( + remote, + model_param.to_string(), + model, + audio_bytes, + extension, + &req.mime_type, + ) + .await + } + } + .internal_err()?; + + Ok(DictationTranscribeResponse { text }) + } + + #[custom_method(DictationConfigRequest)] + async fn on_dictation_config( + &self, + _req: DictationConfigRequest, + ) -> Result { + let config = crate::config::Config::global(); + let mut providers = std::collections::HashMap::new(); + + for def in all_providers() { + let provider = def.provider; + let host = if let Some(host_key) = def.host_key { + config + .get(host_key, false) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + } else { + None + }; + + let provider_key = serde_json::to_value(provider) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| format!("{:?}", provider).to_lowercase()); + providers.insert( + provider_key, + DictationProviderStatusEntry { + configured: is_configured(provider), + host, + description: def.description.to_string(), + uses_provider_config: def.uses_provider_config, + settings_path: def.settings_path.map(|s| s.to_string()), + config_key: if !def.uses_provider_config { + Some(def.config_key.to_string()) + } else { + None + }, + model_config_key: dictation_model_config_key(provider), + default_model: dictation_default_model(provider), + selected_model: dictation_selected_model(config, provider), + available_models: dictation_available_models(provider), + }, + ); + } + + Ok(DictationConfigResponse { providers }) + } + + #[custom_method(DictationModelsListRequest)] + async fn on_dictation_models_list( + &self, + _req: DictationModelsListRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use crate::download_manager::{get_download_manager, DownloadStatus}; + + let manager = get_download_manager(); + let models = whisper::available_models() + .iter() + .map(|model| DictationLocalModelStatus { + id: model.id.to_string(), + label: model.id.to_string(), + description: model.description.to_string(), + size_mb: model.size_mb, + downloaded: model.is_downloaded(), + download_in_progress: manager + .get_progress(model.id) + .map(|progress| progress.status == DownloadStatus::Downloading) + .unwrap_or(false), + }) + .collect(); + + Ok(DictationModelsListResponse { models }) + } + + #[cfg(not(feature = "local-inference"))] + Ok(DictationModelsListResponse::default()) + } + + #[custom_method(DictationModelDownloadRequest)] + async fn on_dictation_model_download( + &self, + _req: DictationModelDownloadRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use crate::download_manager::get_download_manager; + + let model = whisper::get_model(&_req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + let manager = get_download_manager(); + let model_id_for_config = model.id.to_string(); + + manager + .download_model( + model.id.to_string(), + model.url.to_string(), + model.local_path(), + Some(Box::new(move || { + let config = crate::config::Config::global(); + // Only auto-select this model if the user has no model + // currently selected. This prevents silently switching + // the active model mid-session when a user downloads an + // additional model while one is already in use. + let already_selected = config + .get(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|model_id| { + // Treat a deleted model file as no active selection + // so a fresh download can auto-select cleanly. + whisper::get_model(model_id) + .is_some_and(|model| model.is_downloaded()) + }); + if already_selected.is_none() { + if let Err(e) = config.set_param( + whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, + model_id_for_config.clone(), + ) { + error!("Failed to save LOCAL_WHISPER_MODEL after download: {}", e); + } + } + })), + ) + .await + .internal_err()?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelDownloadProgressRequest)] + async fn on_dictation_model_download_progress( + &self, + _req: DictationModelDownloadProgressRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use crate::download_manager::get_download_manager; + + let manager = get_download_manager(); + let progress = + manager + .get_progress(&_req.model_id) + .map(|progress| DictationDownloadProgress { + bytes_downloaded: progress.bytes_downloaded, + total_bytes: progress.total_bytes, + progress_percent: progress.progress_percent, + status: serde_json::to_value(&progress.status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".to_string()), + error: progress.error, + }); + + Ok(DictationModelDownloadProgressResponse { progress }) + } + + #[cfg(not(feature = "local-inference"))] + Ok(DictationModelDownloadProgressResponse { progress: None }) + } + + #[custom_method(DictationModelCancelRequest)] + async fn on_dictation_model_cancel( + &self, + _req: DictationModelCancelRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use crate::download_manager::get_download_manager; + + let manager = get_download_manager(); + manager.cancel_download(&_req.model_id).internal_err()?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelDeleteRequest)] + async fn on_dictation_model_delete( + &self, + _req: DictationModelDeleteRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + let model = whisper::get_model(&_req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + let path = model.local_path(); + + if !path.exists() { + return Err(sacp::Error::invalid_params().data("Model not downloaded")); + } + + std::fs::remove_file(path).internal_err()?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelSelectRequest)] + async fn on_dictation_model_select( + &self, + req: DictationModelSelectRequest, + ) -> Result { + #[cfg(not(feature = "local-inference"))] + if req.provider == "local" { + return Err(sacp::Error::invalid_params().data("Local inference not enabled")); + } + + let provider: DictationProvider = serde_json::from_value(serde_json::Value::String( + req.provider.clone(), + )) + .map_err(|_| { + sacp::Error::invalid_params().data(format!("Unknown provider: {}", req.provider)) + })?; + + let key = match provider { + DictationProvider::OpenAI => OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY, + DictationProvider::Groq => GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY, + DictationProvider::ElevenLabs => ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY, + #[cfg(feature = "local-inference")] + DictationProvider::Local => { + let model = whisper::get_model(&req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + if !model.is_downloaded() { + return Err( + sacp::Error::invalid_params().data("Local Whisper model is not downloaded") + ); + } + whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY + } + }; + + crate::config::Config::global() + .set_param(key, req.model_id) + .internal_err()?; + + Ok(EmptyResponse {}) + } +} + +fn dictation_model_config_key(provider: DictationProvider) -> Option { + match provider { + DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()), + DictationProvider::Groq => Some(GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()), + DictationProvider::ElevenLabs => { + Some(ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()) + } + #[cfg(feature = "local-inference")] + DictationProvider::Local => Some(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY.to_string()), + } +} + +/// Returns the (param_name, default_model) pair used by `transcribe_with_provider` +/// for remote dictation providers. Local inference is handled separately. +fn dictation_transcribe_params(provider: DictationProvider) -> (&'static str, &'static str) { + match provider { + DictationProvider::OpenAI => ("model", OPENAI_TRANSCRIPTION_MODEL), + DictationProvider::Groq => ("model", GROQ_TRANSCRIPTION_MODEL), + DictationProvider::ElevenLabs => ("model_id", ELEVENLABS_TRANSCRIPTION_MODEL), + #[cfg(feature = "local-inference")] + DictationProvider::Local => ("", ""), + } +} + +fn dictation_default_model(provider: DictationProvider) -> Option { + match provider { + DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL.to_string()), + DictationProvider::Groq => Some(GROQ_TRANSCRIPTION_MODEL.to_string()), + DictationProvider::ElevenLabs => Some(ELEVENLABS_TRANSCRIPTION_MODEL.to_string()), + #[cfg(feature = "local-inference")] + DictationProvider::Local => Some(whisper::recommend_model().to_string()), + } +} + +fn dictation_selected_model(config: &Config, provider: DictationProvider) -> Option { + #[cfg(feature = "local-inference")] + if provider == DictationProvider::Local { + return config + .get(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|model_id| whisper::get_model(model_id).is_some()) + .or_else(|| dictation_default_model(provider)); + } + + dictation_model_config_key(provider) + .and_then(|key| { + config + .get(&key, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + }) + .or_else(|| dictation_default_model(provider)) +} + +fn dictation_available_models(provider: DictationProvider) -> Vec { + match provider { + DictationProvider::OpenAI => vec![DictationModelOption { + id: OPENAI_TRANSCRIPTION_MODEL.to_string(), + label: "Whisper-1".to_string(), + description: "OpenAI's hosted Whisper transcription model.".to_string(), + }], + DictationProvider::Groq => vec![DictationModelOption { + id: GROQ_TRANSCRIPTION_MODEL.to_string(), + label: "Whisper Large V3 Turbo".to_string(), + description: "Groq's fast hosted Whisper transcription model.".to_string(), + }], + DictationProvider::ElevenLabs => vec![DictationModelOption { + id: ELEVENLABS_TRANSCRIPTION_MODEL.to_string(), + label: "Scribe v1".to_string(), + description: "ElevenLabs' hosted speech-to-text model.".to_string(), + }], + #[cfg(feature = "local-inference")] + DictationProvider::Local => whisper::available_models() + .iter() + .map(|model| DictationModelOption { + id: model.id.to_string(), + label: model.id.to_string(), + description: model.description.to_string(), + }) + .collect(), + } } pub struct GooseAcpHandler { @@ -2927,7 +3739,6 @@ impl HandleDispatchFrom for GooseAcpHandler { let sid = sid_short(session_id.0.as_ref()); let config_id = req.config_id.0.to_string(); let t_handler = std::time::Instant::now(); - debug!(target: "perf", sid = %sid, config_id = %config_id, value = %value_id.0, "perf: set_config_option start"); match config_id.as_ref() { "provider" => { match agent.update_provider(&session_id.0, &value_id.0, None, None, None).await { @@ -2954,11 +3765,71 @@ impl HandleDispatchFrom for GooseAcpHandler { return Ok(()); } } - let t_tail = std::time::Instant::now(); + // Respond immediately using the current provider inventory snapshot. let (notification, config_options) = agent.build_config_update(&session_id).await?; cx.send_notification(notification)?; responder.respond(SetSessionConfigOptionResponse::new(config_options))?; - debug!(target: "perf", sid = %sid, ms = t_tail.elapsed().as_millis() as u64, "perf: set_config_option notification_and_respond"); + + let maybe_refresh = if config_id == "provider" { + let provider_id = value_id.0.to_string(); + agent + .provider_inventory + .plan_refresh(std::slice::from_ref(&provider_id)) + .await + .ok() + .filter(|plan| plan.started.iter().any(|id| id == &provider_id)) + } else { + None + }; + if maybe_refresh.is_some() { + let agent_bg = agent.clone(); + let cx_bg = cx.clone(); + let session_id_bg = session_id.clone(); + tokio::spawn(async move { + let refreshed = async { + let session_agent = + agent_bg.get_session_agent(&session_id_bg.0, None).await?; + let provider = session_agent + .provider() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let provider_name = provider.get_name().to_string(); + let models = provider + .fetch_recommended_models() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + agent_bg + .provider_inventory + .store_refreshed_models(&provider_name, &models) + .await?; + agent_bg + .build_config_update(&session_id_bg) + .await + .map_err(|e| anyhow::anyhow!(e.to_string())) + } + .await; + + match refreshed { + Ok((fresh_notification, _)) => { + let _ = cx_bg.send_notification(fresh_notification); + } + Err(e) => { + if let Ok(session_agent) = + agent_bg.get_session_agent(&session_id_bg.0, None).await + { + if let Ok(provider) = session_agent.provider().await { + let provider_name = provider.get_name().to_string(); + let _ = agent_bg + .provider_inventory + .store_refresh_error(&provider_name, e.to_string()) + .await; + } + } + } + } + }); + } + debug!(target: "perf", sid = %sid, ms = t_handler.elapsed().as_millis() as u64, config_id = %config_id, "perf: set_config_option done"); Ok(()) } @@ -3081,18 +3952,18 @@ where } pub async fn run(builtins: Vec) -> Result<()> { - register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone()); info!("listening on stdio"); let outgoing = tokio::io::stdout().compat_write(); let incoming = tokio::io::stdin().compat(); - let server = - crate::server_factory::AcpServer::new(crate::server_factory::AcpServerFactoryConfig { + let server = crate::acp::server_factory::AcpServer::new( + crate::acp::server_factory::AcpServerFactoryConfig { builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), - }); + }, + ); let agent = server.create_agent().await?; serve(agent, incoming, outgoing).await } @@ -3100,8 +3971,7 @@ pub async fn run(builtins: Vec) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use goose::conversation::message::{ToolRequest, ToolResponse}; - use goose::providers::errors::ProviderError; + use crate::conversation::message::{ToolRequest, ToolResponse}; use rmcp::model::{CallToolRequestParams, Content as RmcpContent}; use sacp::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, @@ -3291,61 +4161,53 @@ print(\"hello, world\") assert_eq!(outcome_to_confirmation(&input), expected); } - struct MockModelProvider { - models: Result, ProviderError>, - } - - #[async_trait::async_trait] - impl Provider for MockModelProvider { - fn get_name(&self) -> &str { - "mock" - } - - async fn stream( - &self, - _model_config: &goose::model::ModelConfig, - _session_id: &str, - _system: &str, - _messages: &[goose::conversation::message::Message], - _tools: &[rmcp::model::Tool], - ) -> Result { - unimplemented!() - } - - fn get_model_config(&self) -> goose::model::ModelConfig { - goose::model::ModelConfig::new_or_fail("unused") - } - - async fn fetch_recommended_models(&self) -> Result, ProviderError> { - self.models.clone() - } - } - #[test_case( - Ok(vec!["model-a".into(), "model-b".into()]) - => Ok(SessionModelState::new( + vec!["model-a".into(), "model-b".into()] + => SessionModelState::new( ModelId::new("unused"), - vec![ModelInfo::new(ModelId::new("model-a"), "model-a"), + vec![ModelInfo::new(ModelId::new("unused"), "unused"), + ModelInfo::new(ModelId::new("model-a"), "model-a"), ModelInfo::new(ModelId::new("model-b"), "model-b")], - )) + ) ; "returns current and available models" )] #[test_case( - Ok(vec![]) - => Ok(SessionModelState::new(ModelId::new("unused"), vec![])) + vec![] + => SessionModelState::new( + ModelId::new("unused"), + vec![ModelInfo::new(ModelId::new("unused"), "unused")], + ) ; "empty model list" )] - #[test_case( - Err(ProviderError::ExecutionError("fail".into())) - => Err(sacp::Error::internal_error().data("Execution error: fail".to_string())) - ; "fetch error propagates" - )] - #[tokio::test] - async fn test_build_model_state( - models: Result, ProviderError>, - ) -> Result { - let provider = MockModelProvider { models }; - build_model_state(&provider).await + fn test_build_model_state(models: Vec) -> SessionModelState { + let inventory = ProviderInventoryEntry { + provider_id: "mock".to_string(), + provider_name: "Mock".to_string(), + description: "Mock".to_string(), + default_model: "unused".to_string(), + configured: true, + provider_type: crate::providers::base::ProviderType::Builtin, + config_keys: vec![], + setup_steps: vec![], + supports_refresh: true, + refreshing: false, + models: models + .into_iter() + .map(|id| crate::providers::inventory::InventoryModel { + name: id.clone(), + id, + family: None, + context_limit: None, + reasoning: None, + recommended: false, + }) + .collect(), + last_updated_at: None, + last_refresh_attempt_at: None, + last_refresh_error: None, + model_selection_hint: None, + }; + build_model_state("unused", &inventory) } fn json_object(pairs: Vec<(&str, serde_json::Value)>) -> rmcp::model::JsonObject { @@ -3486,6 +4348,80 @@ print(\"hello, world\") .map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect()) } + fn make_session_with_usage( + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + accumulated_total_tokens: Option, + accumulated_input_tokens: Option, + accumulated_output_tokens: Option, + ) -> Session { + Session { + id: "session-1".to_string(), + working_dir: PathBuf::from("/tmp"), + name: "ACP Session".to_string(), + user_set_name: false, + session_type: SessionType::Acp, + created_at: Default::default(), + updated_at: Default::default(), + extension_data: crate::session::ExtensionData::default(), + total_tokens, + input_tokens, + output_tokens, + accumulated_total_tokens, + accumulated_input_tokens, + accumulated_output_tokens, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + goose_mode: GooseMode::default(), + thread_id: None, + } + } + + #[test] + fn test_build_prompt_usage_uses_current_turn_tokens() { + let session = make_session_with_usage( + Some(120), + Some(80), + Some(40), + Some(360), + Some(210), + Some(150), + ); + let usage = build_prompt_usage(&session).expect("usage should be present"); + assert_eq!(usage.total_tokens, 120); + assert_eq!(usage.input_tokens, 80); + assert_eq!(usage.output_tokens, 40); + } + + #[test] + fn test_build_prompt_usage_falls_back_to_current_tokens() { + let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None); + let usage = build_prompt_usage(&session).expect("usage should be present"); + assert_eq!(usage.total_tokens, 120); + assert_eq!(usage.input_tokens, 80); + assert_eq!(usage.output_tokens, 40); + } + + #[test] + fn test_build_prompt_usage_requires_total_tokens() { + let session = make_session_with_usage(None, Some(80), Some(40), None, None, None); + assert!(build_prompt_usage(&session).is_none()); + } + + #[test] + fn test_build_usage_update_clamps_negative_used_to_zero() { + let session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None); + let usage = build_usage_update(&session, 258_000); + assert_eq!(usage.used, 0); + assert_eq!(usage.size, 258_000); + } + #[test_case( GooseMode::Auto => Ok(SessionModeState::new( diff --git a/crates/goose-acp/src/server_factory.rs b/crates/goose/src/acp/server_factory.rs similarity index 80% rename from crates/goose-acp/src/server_factory.rs rename to crates/goose/src/acp/server_factory.rs index 5c63d0175c1a..dbbc9d1a074f 100644 --- a/crates/goose-acp/src/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -1,9 +1,8 @@ +use crate::acp::server::{AcpProviderFactory, GooseAcpAgent}; use anyhow::Result; use std::sync::Arc; use tracing::info; -use crate::server::{AcpProviderFactory, GooseAcpAgent}; - pub struct AcpServerFactoryConfig { pub builtins: Vec, pub data_dir: std::path::PathBuf, @@ -23,18 +22,18 @@ impl AcpServer { let config_path = self .config .config_dir - .join(goose::config::base::CONFIG_YAML_NAME); - let config = goose::config::Config::new(&config_path, "goose")?; + .join(crate::config::base::CONFIG_YAML_NAME); + let config = crate::config::Config::new(&config_path, "goose")?; let goose_mode = config .get_goose_mode() - .unwrap_or(goose::config::GooseMode::Auto); + .unwrap_or(crate::config::GooseMode::Auto); let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false); let provider_factory: AcpProviderFactory = Arc::new(move |provider_name, model_config, extensions| { Box::pin(async move { - goose::providers::create(&provider_name, model_config, extensions).await + crate::providers::create(&provider_name, model_config, extensions).await }) }); diff --git a/crates/goose-acp/src/tools.rs b/crates/goose/src/acp/tools.rs similarity index 100% rename from crates/goose-acp/src/tools.rs rename to crates/goose/src/acp/tools.rs diff --git a/crates/goose/src/acp/transport/connection.rs b/crates/goose/src/acp/transport/connection.rs new file mode 100644 index 000000000000..3ab9179fa4d5 --- /dev/null +++ b/crates/goose/src/acp/transport/connection.rs @@ -0,0 +1,265 @@ +//! Connection-level state shared between HTTP and WebSocket transports. +//! +//! Each connection hosts one ACP agent task. All server→client messages for +//! the connection are multicast through a single broadcast channel; HTTP GET +//! SSE streams and WebSocket sinks subscribe to that channel. POSTs (and WS +//! text frames) forward client→server messages into the agent over an mpsc. + +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, +}; + +use anyhow::Result; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use tracing::{error, info, warn}; + +use crate::acp::adapters::{ReceiverToAsyncRead, SenderToAsyncWrite}; +use crate::acp::server_factory::AcpServer; + +/// Broadcast capacity for agent→client messages. Large enough to buffer a +/// typical prompt's streaming notifications even if the subscriber is briefly +/// slow (e.g. during reconnect). +const OUTBOUND_BROADCAST_CAPACITY: usize = 1024; + +/// Maximum number of server→client messages to retain while no subscriber is +/// attached. In the HTTP flow the client opens `GET /acp` only after receiving +/// the initialize response, so any notifications or server-initiated requests +/// emitted by the agent in that window would otherwise be broadcast to zero +/// subscribers and permanently lost. We buffer them here and replay on the +/// first subscribe. On overflow the oldest message is dropped with a warning. +const PRE_SUBSCRIBE_BUFFER_CAPACITY: usize = 1024; + +pub(crate) struct Connection { + /// Send client→server messages into the agent. + pub to_agent_tx: mpsc::Sender, + /// Subscribe here to receive all server→client messages for this connection. + pub outbound_tx: broadcast::Sender, + /// Pulled exactly once during `initialize` to read the synchronous response + /// that must be returned as the HTTP 200 body before any broadcast + /// subscribers exist. `None` once consumed. + pub init_receiver: Mutex>>, + /// Set once the initialize handler has captured the initialize response and + /// handed ownership of the agent output pump over to the broadcast fan-out. + pub init_complete: Mutex, + /// Handle to the agent task; aborted on connection termination. + pub agent_handle: tokio::task::JoinHandle<()>, + /// Handle to the fan-out pump task; aborted on connection termination. + pub pump_handle: Mutex>>, + pre_subscribe_buffer: Arc>>>, +} + +pub(crate) struct ConnectionRegistry { + pub server: Arc, + connections: RwLock>>, +} + +impl ConnectionRegistry { + pub fn new(server: Arc) -> Self { + Self { + server, + connections: RwLock::new(HashMap::new()), + } + } + + /// Create a new connection, spawn the ACP agent task, and return + /// (connection_id, connection). The initialize request body should be sent + /// via `connection.to_agent_tx` and the synchronous initialize response + /// read via `consume_initialize_response`. + pub async fn create_connection(&self) -> Result<(String, Arc)> { + let (to_agent_tx, to_agent_rx) = mpsc::channel::(256); + let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::(); + let (outbound_tx, _) = broadcast::channel::(OUTBOUND_BROADCAST_CAPACITY); + + let agent = self.server.create_agent().await?; + let connection_id = uuid::Uuid::new_v4().to_string(); + + let read_stream = ReceiverToAsyncRead::new(to_agent_rx); + let write_stream = SenderToAsyncWrite::new(from_agent_tx); + let fut = + crate::acp::server::serve(agent, read_stream.compat(), write_stream.compat_write()); + + let conn_id_for_task = connection_id.clone(); + let agent_handle = tokio::spawn(async move { + if let Err(e) = fut.await { + error!(connection_id = %conn_id_for_task, "ACP agent task error: {}", e); + } + }); + + let connection = Arc::new(Connection { + to_agent_tx, + outbound_tx, + init_receiver: Mutex::new(Some(from_agent_rx)), + init_complete: Mutex::new(false), + agent_handle, + pump_handle: Mutex::new(None), + pre_subscribe_buffer: Arc::new(Mutex::new(Some(VecDeque::new()))), + }); + + self.connections + .write() + .await + .insert(connection_id.clone(), connection.clone()); + + info!(connection_id = %connection_id, "Connection created"); + Ok((connection_id, connection)) + } + + pub async fn get(&self, connection_id: &str) -> Option> { + self.connections.read().await.get(connection_id).cloned() + } + + pub async fn remove(&self, connection_id: &str) -> Option> { + self.connections.write().await.remove(connection_id) + } +} + +impl Connection { + /// After the synchronous initialize response has been consumed, spawn a + /// task that forwards all remaining agent output to the broadcast channel. + /// Idempotent. + pub async fn start_fanout(self: &Arc) { + let mut complete = self.init_complete.lock().await; + if *complete { + return; + } + let Some(mut rx) = self.init_receiver.lock().await.take() else { + return; + }; + let outbound_tx = self.outbound_tx.clone(); + let buffer = self.pre_subscribe_buffer.clone(); + let handle = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + let mut buf_guard = buffer.lock().await; + match buf_guard.as_mut() { + Some(buf) => { + if buf.len() >= PRE_SUBSCRIBE_BUFFER_CAPACITY { + warn!( + "Pre-subscribe buffer full ({} messages); dropping oldest", + PRE_SUBSCRIBE_BUFFER_CAPACITY + ); + buf.pop_front(); + } + buf.push_back(msg); + } + None => { + drop(buf_guard); + let _ = outbound_tx.send(msg); + } + } + } + }); + *self.pump_handle.lock().await = Some(handle); + *complete = true; + } + + pub async fn subscribe_with_replay(&self) -> (Vec, broadcast::Receiver) { + let mut guard = self.pre_subscribe_buffer.lock().await; + let receiver = self.outbound_tx.subscribe(); + let replay = guard.take().map(Vec::from).unwrap_or_default(); + (replay, receiver) + } + + /// Terminate the connection: abort the agent task and the fan-out pump. + pub async fn shutdown(&self) { + self.agent_handle.abort(); + if let Some(h) = self.pump_handle.lock().await.take() { + h.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::time::timeout; + + fn fake_connection() -> (Arc, mpsc::UnboundedSender) { + let (to_agent_tx, _to_agent_rx) = mpsc::channel::(256); + let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::(); + let (outbound_tx, _) = broadcast::channel::(OUTBOUND_BROADCAST_CAPACITY); + + let agent_handle = tokio::spawn(async { + std::future::pending::<()>().await; + }); + + let connection = Arc::new(Connection { + to_agent_tx, + outbound_tx, + init_receiver: Mutex::new(Some(from_agent_rx)), + init_complete: Mutex::new(false), + agent_handle, + pump_handle: Mutex::new(None), + pre_subscribe_buffer: Arc::new(Mutex::new(Some(VecDeque::new()))), + }); + + (connection, from_agent_tx) + } + + #[tokio::test] + async fn buffers_messages_emitted_before_first_subscribe() { + let (conn, agent_tx) = fake_connection(); + conn.start_fanout().await; + + agent_tx.send("one".to_string()).unwrap(); + agent_tx.send("two".to_string()).unwrap(); + agent_tx.send("three".to_string()).unwrap(); + + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(20)).await; + + let (replay, _rx) = conn.subscribe_with_replay().await; + assert_eq!(replay, vec!["one", "two", "three"]); + + conn.shutdown().await; + } + + #[tokio::test] + async fn switches_to_live_broadcast_after_subscribe() { + let (conn, agent_tx) = fake_connection(); + conn.start_fanout().await; + + let (replay, mut rx) = conn.subscribe_with_replay().await; + assert!(replay.is_empty()); + + agent_tx.send("live-one".to_string()).unwrap(); + agent_tx.send("live-two".to_string()).unwrap(); + + let got1 = timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(); + let got2 = timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(got1, "live-one"); + assert_eq!(got2, "live-two"); + + conn.shutdown().await; + } + + #[tokio::test] + async fn pre_subscribe_buffer_is_bounded() { + let (conn, agent_tx) = fake_connection(); + conn.start_fanout().await; + + for i in 0..(PRE_SUBSCRIBE_BUFFER_CAPACITY + 50) { + agent_tx.send(format!("m{}", i)).unwrap(); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + + let (replay, _rx) = conn.subscribe_with_replay().await; + assert_eq!(replay.len(), PRE_SUBSCRIBE_BUFFER_CAPACITY); + assert_eq!( + replay.last().unwrap(), + &format!("m{}", PRE_SUBSCRIBE_BUFFER_CAPACITY + 49) + ); + assert_eq!(replay.first().unwrap(), &format!("m{}", 50)); + + conn.shutdown().await; + } +} diff --git a/crates/goose/src/acp/transport/http.rs b/crates/goose/src/acp/transport/http.rs new file mode 100644 index 000000000000..3694cfbe0f43 --- /dev/null +++ b/crates/goose/src/acp/transport/http.rs @@ -0,0 +1,266 @@ +use std::{convert::Infallible, sync::Arc, time::Duration}; + +use axum::{ + body::Body, + extract::State, + http::{HeaderValue, Request, StatusCode}, + response::{IntoResponse, Response, Sse}, +}; +use http_body_util::BodyExt; +use serde_json::Value; +use tokio::sync::broadcast; +use tracing::{debug, error, info, trace}; + +use super::connection::{Connection, ConnectionRegistry}; +use super::*; + +/// POST /acp +/// +/// - `initialize`: creates a new connection, forwards the request, waits for +/// the synchronous initialize response from the agent, and returns it as a +/// 200 OK JSON body with the `Acp-Connection-Id` header set. +/// - All other messages: require `Acp-Connection-Id` (and `Acp-Session-Id` +/// for session-scoped methods), forward to the agent, return 202 Accepted. +pub(crate) async fn handle_post( + State(registry): State>, + request: Request, +) -> Response { + if !content_type_is_json(&request) { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Unsupported Media Type: Content-Type must be application/json", + ) + .into_response(); + } + + let connection_id = header_value(&request, HEADER_CONNECTION_ID); + let session_id = header_value(&request, HEADER_SESSION_ID); + + let body_bytes = match request.into_body().collect().await { + Ok(collected) => collected.to_bytes(), + Err(e) => { + error!("Failed to read request body: {}", e); + return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response(); + } + }; + + let json_message: Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(); + } + }; + + if json_message.is_array() { + return ( + StatusCode::NOT_IMPLEMENTED, + "Batch requests are not supported", + ) + .into_response(); + } + + if is_initialize_request(&json_message) { + return handle_initialize(registry, json_message).await; + } + + let Some(connection_id) = connection_id else { + return ( + StatusCode::BAD_REQUEST, + "Bad Request: Acp-Connection-Id header required", + ) + .into_response(); + }; + + let Some(connection) = registry.get(&connection_id).await else { + return (StatusCode::NOT_FOUND, "Unknown Acp-Connection-Id").into_response(); + }; + + if let Some(method) = json_message.get("method").and_then(|m| m.as_str()) { + if method_requires_session_header(method) && session_id.is_none() { + return ( + StatusCode::BAD_REQUEST, + "Bad Request: Acp-Session-Id header required for session-scoped methods", + ) + .into_response(); + } + } + + if !is_jsonrpc_request_with_id(&json_message) + && !is_jsonrpc_notification(&json_message) + && !is_jsonrpc_response(&json_message) + { + return (StatusCode::BAD_REQUEST, "Invalid JSON-RPC message").into_response(); + } + + let message_str = serde_json::to_string(&json_message).unwrap(); + trace!(connection_id = %connection_id, payload = %message_str, "POST → agent"); + if connection.to_agent_tx.send(message_str).await.is_err() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to forward message to agent", + ) + .into_response(); + } + + StatusCode::ACCEPTED.into_response() +} + +async fn handle_initialize(registry: Arc, json_message: Value) -> Response { + let (connection_id, connection) = match registry.create_connection().await { + Ok(pair) => pair, + Err(e) => { + error!("Failed to create connection: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create connection", + ) + .into_response(); + } + }; + + let message_str = serde_json::to_string(&json_message).unwrap(); + trace!(connection_id = %connection_id, payload = %message_str, "initialize → agent"); + if connection.to_agent_tx.send(message_str).await.is_err() { + registry.remove(&connection_id).await; + connection.shutdown().await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to forward initialize to agent", + ) + .into_response(); + } + + // Read exactly one message from the agent: the initialize response. + let init_response = { + let mut guard = connection.init_receiver.lock().await; + let Some(rx) = guard.as_mut() else { + registry.remove(&connection_id).await; + connection.shutdown().await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Initialize receiver already consumed", + ) + .into_response(); + }; + rx.recv().await + }; + + let init_response = match init_response { + Some(msg) => msg, + None => { + registry.remove(&connection_id).await; + connection.shutdown().await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Agent closed before initialize response", + ) + .into_response(); + } + }; + + connection.start_fanout().await; + + let mut response = ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, JSON_MIME_TYPE)], + init_response, + ) + .into_response(); + if let Ok(v) = HeaderValue::from_str(&connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, v); + } + info!(connection_id = %connection_id, "Initialize complete"); + response +} + +/// GET /acp (no Upgrade) +/// +/// Opens the single long-lived SSE stream for a connection. All server→client +/// messages (responses + notifications + server-initiated requests) are +/// delivered here, correlated by their JSON-RPC body fields. +pub(crate) async fn handle_get( + registry: Arc, + request: Request, +) -> Response { + if !accepts_mime_type(&request, EVENT_STREAM_MIME_TYPE) { + return ( + StatusCode::NOT_ACCEPTABLE, + "Not Acceptable: Client must accept text/event-stream", + ) + .into_response(); + } + + let Some(connection_id) = header_value(&request, HEADER_CONNECTION_ID) else { + return ( + StatusCode::BAD_REQUEST, + "Bad Request: Acp-Connection-Id header required", + ) + .into_response(); + }; + + let Some(connection) = registry.get(&connection_id).await else { + return (StatusCode::NOT_FOUND, "Unknown Acp-Connection-Id").into_response(); + }; + + let (replay, receiver) = connection.subscribe_with_replay().await; + let sse = build_sse_stream(connection.clone(), replay, receiver); + + let mut response = sse.into_response(); + if let Ok(v) = HeaderValue::from_str(&connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, v); + } + response +} + +fn build_sse_stream( + _connection: Arc, + replay: Vec, + mut receiver: broadcast::Receiver, +) -> Sse>> { + let stream = async_stream::stream! { + for msg in replay { + trace!(payload = %msg, "SSE → client (replay)"); + yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg)); + } + loop { + match receiver.recv().await { + Ok(msg) => { + trace!(payload = %msg, "SSE → client"); + yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg)); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + debug!("SSE subscriber lagged {} messages", n); + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + + Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text(""), + ) +} + +/// DELETE /acp +pub(crate) async fn handle_delete( + State(registry): State>, + request: Request, +) -> Response { + let Some(connection_id) = header_value(&request, HEADER_CONNECTION_ID) else { + return ( + StatusCode::BAD_REQUEST, + "Bad Request: Acp-Connection-Id header required", + ) + .into_response(); + }; + + let Some(connection) = registry.remove(&connection_id).await else { + return (StatusCode::NOT_FOUND, "Unknown Acp-Connection-Id").into_response(); + }; + connection.shutdown().await; + info!(connection_id = %connection_id, "Connection terminated via DELETE"); + StatusCode::ACCEPTED.into_response() +} diff --git a/crates/goose-acp/src/transport.rs b/crates/goose/src/acp/transport/mod.rs similarity index 59% rename from crates/goose-acp/src/transport.rs rename to crates/goose/src/acp/transport/mod.rs index dc6e8d0d43c4..a9038431be3d 100644 --- a/crates/goose-acp/src/transport.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -1,3 +1,4 @@ +pub mod connection; pub mod http; pub mod websocket; @@ -9,27 +10,21 @@ use axum::{ ws::{rejection::WebSocketUpgradeRejection, WebSocketUpgrade}, State, }, - http::{header, Method, Request}, + http::{header, HeaderName, Method, Request}, response::Response, routing::{delete, get, post}, Router, }; use serde_json::Value; -use tokio::sync::{mpsc, Mutex}; use tower_http::cors::{Any, CorsLayer}; -use crate::server_factory::AcpServer; +use crate::acp::server_factory::AcpServer; +pub(crate) const HEADER_CONNECTION_ID: &str = "Acp-Connection-Id"; pub(crate) const HEADER_SESSION_ID: &str = "Acp-Session-Id"; pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; pub(crate) const JSON_MIME_TYPE: &str = "application/json"; -pub(crate) struct TransportSession { - pub to_agent_tx: mpsc::Sender, - pub from_agent_rx: Arc>>, - pub handle: tokio::task::JoinHandle<()>, -} - pub(crate) fn accepts_mime_type(request: &Request, mime_type: &str) -> bool { request .headers() @@ -38,16 +33,6 @@ pub(crate) fn accepts_mime_type(request: &Request, mime_type: &str) -> boo .is_some_and(|accept| accept.contains(mime_type)) } -pub(crate) fn accepts_json_and_sse(request: &Request) -> bool { - request - .headers() - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .is_some_and(|accept| { - accept.contains(JSON_MIME_TYPE) && accept.contains(EVENT_STREAM_MIME_TYPE) - }) -} - pub(crate) fn content_type_is_json(request: &Request) -> bool { request .headers() @@ -56,15 +41,15 @@ pub(crate) fn content_type_is_json(request: &Request) -> bool { .is_some_and(|ct| ct.starts_with(JSON_MIME_TYPE)) } -pub(crate) fn get_session_id(request: &Request) -> Option { +pub(crate) fn header_value(request: &Request, name: &str) -> Option { request .headers() - .get(HEADER_SESSION_ID) + .get(name) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) } -pub(crate) fn is_jsonrpc_request(value: &Value) -> bool { +pub(crate) fn is_jsonrpc_request_with_id(value: &Value) -> bool { value.get("method").is_some() && value.get("id").is_some() } @@ -73,21 +58,35 @@ pub(crate) fn is_jsonrpc_notification(value: &Value) -> bool { } pub(crate) fn is_jsonrpc_response(value: &Value) -> bool { - value.get("id").is_some() && (value.get("result").is_some() || value.get("error").is_some()) + value.get("id").is_some() + && value.get("method").is_none() + && (value.get("result").is_some() || value.get("error").is_some()) } pub(crate) fn is_initialize_request(value: &Value) -> bool { value.get("method").is_some_and(|m| m == "initialize") && value.get("id").is_some() } +/// Methods that are scoped to a session and require an Acp-Session-Id header. +pub(crate) fn method_requires_session_header(method: &str) -> bool { + matches!( + method, + "session/prompt" + | "session/cancel" + | "session/load" + | "session/set_mode" + | "session/set_model" + ) +} + async fn handle_get( ws_upgrade: Result, - State(state): State<(Arc, Arc)>, + State(state): State>, request: Request, ) -> Response { match ws_upgrade { - Ok(ws) => websocket::handle_get(state.1, ws).await, - Err(_) => http::handle_get(state.0, request).await, + Ok(ws) => websocket::handle_ws_upgrade(state, ws).await, + Err(_) => http::handle_get(state, request).await, } } @@ -96,8 +95,7 @@ async fn health() -> &'static str { } pub fn create_router(server: Arc) -> Router { - let http_state = Arc::new(http::HttpState::new(server.clone())); - let ws_state = Arc::new(websocket::WsState::new(server)); + let registry = Arc::new(connection::ConnectionRegistry::new(server)); let cors = CorsLayer::new() .allow_origin(Any) @@ -105,24 +103,23 @@ pub fn create_router(server: Arc) -> Router { .allow_headers([ header::CONTENT_TYPE, header::ACCEPT, - HEADER_SESSION_ID.parse().unwrap(), + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), header::SEC_WEBSOCKET_VERSION, header::SEC_WEBSOCKET_KEY, header::CONNECTION, header::UPGRADE, + ]) + .expose_headers([ + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), ]); Router::new() .route("/health", get(health)) .route("/status", get(health)) - .route( - "/acp", - post(http::handle_post).with_state(http_state.clone()), - ) - .route( - "/acp", - get(handle_get).with_state((http_state.clone(), ws_state)), - ) - .route("/acp", delete(http::handle_delete).with_state(http_state)) + .route("/acp", post(http::handle_post).with_state(registry.clone())) + .route("/acp", get(handle_get).with_state(registry.clone())) + .route("/acp", delete(http::handle_delete).with_state(registry)) .layer(cors) } diff --git a/crates/goose/src/acp/transport/websocket.rs b/crates/goose/src/acp/transport/websocket.rs new file mode 100644 index 000000000000..752cb3202eb4 --- /dev/null +++ b/crates/goose/src/acp/transport/websocket.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + http::{HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use futures::{SinkExt, StreamExt}; +use tracing::{debug, error, info, trace, warn}; + +use super::connection::ConnectionRegistry; +use super::HEADER_CONNECTION_ID; + +/// GET /acp with `Upgrade: websocket` +/// +/// Creates a new connection (same lifecycle as Streamable HTTP), upgrades to a +/// WebSocket, and runs a bidirectional message loop. The client still sends +/// `initialize` as the first WS text frame — unlike the HTTP path, the +/// initialize response is streamed back over the same WebSocket rather than +/// returned synchronously. +pub(crate) async fn handle_ws_upgrade( + registry: Arc, + ws: WebSocketUpgrade, +) -> Response { + let (connection_id, connection) = match registry.create_connection().await { + Ok(pair) => pair, + Err(e) => { + error!("Failed to create WebSocket connection: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create WebSocket connection", + ) + .into_response(); + } + }; + + // WebSocket does not need the synchronous initialize split — start the + // broadcast fan-out immediately so the WS sink reads from the same stream + // of server→client messages as any HTTP SSE subscribers would. + connection.start_fanout().await; + + let conn_id_for_handler = connection_id.clone(); + let registry_for_handler = registry.clone(); + let mut response = ws.on_upgrade(move |socket| async move { + run_ws( + socket, + registry_for_handler, + conn_id_for_handler, + connection, + ) + .await + }); + + if let Ok(v) = HeaderValue::from_str(&connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, v); + } + info!(connection_id = %connection_id, "WebSocket connection created"); + response +} + +async fn run_ws( + socket: WebSocket, + registry: Arc, + connection_id: String, + connection: Arc, +) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let (replay, mut outbound_rx) = connection.subscribe_with_replay().await; + + debug!(connection_id = %connection_id, "Starting WebSocket message loop"); + + for text in replay { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client (replay): {} bytes", text.len()); + if ws_tx.send(Message::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed during replay"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } + return; + } + } + + loop { + tokio::select! { + msg_result = ws_rx.next() => { + match msg_result { + Some(Ok(Message::Text(text))) => { + let text_str = text.to_string(); + trace!(connection_id = %connection_id, payload = %text_str, "Client → Agent: {} bytes", text_str.len()); + if connection.to_agent_tx.send(text_str).await.is_err() { + error!(connection_id = %connection_id, "Agent channel closed"); + break; + } + } + Some(Ok(Message::Close(frame))) => { + debug!(connection_id = %connection_id, "Client closed connection: {:?}", frame); + break; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + Some(Ok(Message::Binary(_))) => { + warn!(connection_id = %connection_id, "Ignoring binary message (ACP uses text)"); + continue; + } + Some(Err(e)) => { + error!(connection_id = %connection_id, "WebSocket error: {}", e); + break; + } + None => break, + } + } + + recv = outbound_rx.recv() => { + match recv { + Ok(text) => { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client: {} bytes", text.len()); + if ws_tx.send(Message::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed"); + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!(connection_id = %connection_id, "WebSocket lagged {} messages", n); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + + debug!(connection_id = %connection_id, "Cleaning up WebSocket connection"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } +} diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 57446f07d14b..a1522ca72486 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -21,6 +21,7 @@ use crate::agents::extension_manager::{ get_parameter_names, ExtensionManager, ExtensionManagerCapabilities, }; use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME}; +use crate::agents::platform_extensions::summon::discover_filesystem_sources; use crate::agents::platform_extensions::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; use crate::agents::prompt_manager::PromptManager; @@ -354,10 +355,17 @@ impl Agent { } let initial_messages = conversation.messages().clone(); - let (tools, toolshim_tools, system_prompt) = self + let (tools, toolshim_tools, mut system_prompt) = self .prepare_tools_and_prompt(session_id, working_dir) .await?; + if let Some(instructions) = self.resolve_at_mention(&conversation, working_dir) { + system_prompt = format!( + "{}\n\n# Instructions from active agent:\n\n{}", + system_prompt, instructions + ); + } + let goose_mode = *self.current_goose_mode.lock().await; if goose_mode == GooseMode::SmartApprove { @@ -391,6 +399,30 @@ impl Agent { }) } + fn resolve_at_mention( + &self, + conversation: &Conversation, + working_dir: &std::path::Path, + ) -> Option { + let last_message = conversation.messages().last()?; + if last_message.role == rmcp::model::Role::User { + let after_at = last_message + .as_concat_text() + .trim() + .strip_prefix('@')? + .to_lowercase(); + + for source in discover_filesystem_sources(working_dir) { + let name = source.name.to_lowercase(); + let is_match = after_at == name || after_at.starts_with(&format!("{} ", name)); + if is_match && !source.content.is_empty() { + return Some(source.content.clone()); + } + } + } + None + } + async fn categorize_tools( &self, response: &Message, @@ -2051,7 +2083,7 @@ impl Agent { .await?; let extensions_info = self .extension_manager - .get_extensions_info(&session.working_dir) + .get_extensions_info(session_id, &session.working_dir) .await; tracing::debug!("Retrieved {} extensions info", extensions_info.len()); let (extension_count, tool_count) = self diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index b49713854e2d..3a488c70a1fb 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -140,8 +140,8 @@ impl Agent { } async fn handle_skills_command(&self, session_id: &str) -> Result> { - use super::platform_extensions::skills::list_installed_skills; - use super::platform_extensions::SourceKind; + use crate::skills::list_installed_skills; + use goose_sdk::custom_requests::SourceType; let working_dir = self .config @@ -153,7 +153,7 @@ impl Agent { let sources = list_installed_skills(working_dir.as_deref()); let skills: Vec<_> = sources .iter() - .filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill)) + .filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill)) .collect(); let mut output = String::new(); @@ -165,7 +165,7 @@ impl Agent { } else { output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len())); for skill in &skills { - let kind_label = if skill.kind == SourceKind::BuiltinSkill { + let kind_label = if skill.source_type == SourceType::BuiltinSkill { " *(builtin)*" } else { "" diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index e534c0a3b827..d8004879c108 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -77,6 +77,12 @@ struct Extension { client: McpClientBox, server_info: Option, _temp_dir: Option, + /// Cache of MCP-served skills discovered from this extension's + /// `skill://index.json` at connect time. Populated synchronously + /// during extension registration (see `populate_mcp_skills_cache`). + /// Empty for extensions that don't serve a parseable index. + /// TODO: invalidate on `notifications/resources/list_changed`. + mcp_skills: Vec, } impl Extension { @@ -86,6 +92,7 @@ impl Extension { client: McpClientBox, server_info: Option, temp_dir: Option, + mcp_skills: Vec, ) -> Self { Self { client, @@ -93,6 +100,7 @@ impl Extension { resolved_config, server_info, _temp_dir: temp_dir, + mcp_skills, } } @@ -590,6 +598,38 @@ async fn create_unix_socket_http_client( Ok(Box::new(client_res?)) } +/// Fetches the MCP skills cache for a newly-connected extension, bounded by +/// `INDEX_FETCH_TIMEOUT`. On timeout or error the cache is empty and +/// extension registration still succeeds. +/// +/// Requires a real session id: `McpClient::set_session_id` asserts a single +/// session per client lifetime, so passing `""` would lock the client and +/// panic on the first real-session request. Callers without a session (ACP +/// bootstrap, CLI scenario tests) pass `None` and get an empty cache; a +/// later reconnect with a real session populates it. +async fn populate_mcp_skills_cache( + server_name: &str, + client: &dyn McpClientTrait, + session_id: Option<&str>, +) -> Vec { + match session_id { + Some(sid) => crate::skills::mcp_client::fetch_server_skills( + server_name, + client, + sid, + CancellationToken::new(), + ) + .await, + None => { + tracing::debug!( + server = %server_name, + "skipping skill index fetch: no session id at registration time" + ); + Vec::new() + } + } +} + impl ExtensionManager { pub fn new( provider: SharedProvider, @@ -656,14 +696,50 @@ impl ExtensionManager { // restart if both match. let resolved_config = config.clone().resolve(Config::global()).await?; - if let Some(existing) = self.extensions.lock().await.get(&sanitized_name) { - if existing.config == config && existing.resolved_config == resolved_config { - return Ok(()); + // Fast path: if the extension is already registered with an identical + // config, skip the restart. Two wrinkles: + // 1. If the skill cache is empty and we now have a session id, the + // earlier registration likely passed `session_id=None` (e.g. the + // `extensionmanager.add_extension` tool path) and never fetched + // `skill://index.json`. Repopulate in place rather than forcing + // the user to reconnect. + // 2. Otherwise it's a true no-op. + let repopulate_client: Option = { + let extensions = self.extensions.lock().await; + match extensions.get(&sanitized_name) { + Some(existing) + if existing.config == config + && existing.resolved_config == resolved_config => + { + if existing.mcp_skills.is_empty() && session_id.is_some() { + Some(existing.client.clone()) + } else { + return Ok(()); + } + } + Some(_) => { + tracing::debug!( + name = sanitized_name, + "extension config changed, restarting with updated config" + ); + None + } + None => None, } - tracing::debug!( - name = sanitized_name, - "extension config changed, restarting with updated config" - ); + }; + + if let Some(client) = repopulate_client { + let refreshed = + populate_mcp_skills_cache(&sanitized_name, client.as_ref(), session_id).await; + let mut extensions = self.extensions.lock().await; + if let Some(existing) = extensions.get_mut(&sanitized_name) { + // Guard against a concurrent restart having replaced the + // extension between the drop and reacquire. + if existing.config == config && existing.resolved_config == resolved_config { + existing.mcp_skills = refreshed; + } + } + return Ok(()); } let mut temp_dir = None; @@ -900,6 +976,10 @@ impl ExtensionManager { }; let server_info = client.get_info().cloned(); + let client_arc: McpClientBox = Arc::from(client); + + let mcp_skills = + populate_mcp_skills_cache(&sanitized_name, client_arc.as_ref(), session_id).await; let mut extensions = self.extensions.lock().await; extensions.insert( @@ -907,9 +987,10 @@ impl ExtensionManager { Extension::new( config, resolved_config, - Arc::from(client), + client_arc, server_info, temp_dir, + mcp_skills, ), ); drop(extensions); @@ -925,28 +1006,70 @@ impl ExtensionManager { client: McpClientBox, info: Option, temp_dir: Option, + session_id: Option<&str>, ) { let normalized = name_to_key(&name); + + let mcp_skills = populate_mcp_skills_cache(&normalized, client.as_ref(), session_id).await; + self.extensions.lock().await.insert( normalized, - Extension::new(config.clone(), config.clone(), client, info, temp_dir), + Extension::new( + config.clone(), + config.clone(), + client, + info, + temp_dir, + mcp_skills, + ), ); self.invalidate_tools_cache_and_bump_version().await; } - /// Get extensions info for building the system prompt - pub async fn get_extensions_info(&self, working_dir: &std::path::Path) -> Vec { + /// Get extensions info for building the system prompt. Combines each + /// extension's static `InitializeResult.instructions` with any per-turn + /// dynamic contribution via `McpClientTrait::get_dynamic_instructions`. + pub async fn get_extensions_info( + &self, + session_id: &str, + working_dir: &std::path::Path, + ) -> Vec { let working_dir_str = working_dir.to_string_lossy(); - self.extensions - .lock() - .await - .iter() - .map(|(name, ext)| { - let instructions = ext.get_instructions().unwrap_or_default(); - let instructions = instructions.replace("{{WORKING_DIR}}", &working_dir_str); - ExtensionInfo::new(name, &instructions, ext.supports_resources()) - }) - .collect() + + let snapshots: Vec<(String, String, McpClientBox, bool)> = { + let extensions = self.extensions.lock().await; + extensions + .iter() + .map(|(name, ext)| { + ( + name.clone(), + ext.get_instructions().unwrap_or_default(), + ext.client.clone(), + ext.supports_resources(), + ) + }) + .collect() + }; + + let mut infos = Vec::with_capacity(snapshots.len()); + for (name, static_instructions, client, supports_resources) in snapshots { + let dynamic = client + .get_dynamic_instructions(session_id) + .await + .unwrap_or_default(); + + let combined = if dynamic.is_empty() { + static_instructions + } else if static_instructions.is_empty() { + dynamic + } else { + format!("{}\n{}", static_instructions, dynamic) + }; + + let combined = combined.replace("{{WORKING_DIR}}", &working_dir_str); + infos.push(ExtensionInfo::new(&name, &combined, supports_resources)); + } + infos } /// Get aggregated usage statistics @@ -982,6 +1105,19 @@ impl ExtensionManager { Ok(self.extensions.lock().await.keys().cloned().collect()) } + /// Snapshot every connected extension's cached MCP skill entries. Read + /// path for the skills platform extension's per-turn system-prompt + /// assembly — no network I/O. + pub async fn aggregated_mcp_skills( + &self, + ) -> Vec { + let mut out = Vec::new(); + for ext in self.extensions.lock().await.values() { + out.extend(ext.mcp_skills.iter().cloned()); + } + out + } + pub async fn is_extension_enabled(&self, name: &str) -> bool { let normalized = name_to_key(name); self.extensions.lock().await.contains_key(&normalized) @@ -1259,6 +1395,36 @@ impl ExtensionManager { )) } + /// Returns the raw `resources/list` result for a single extension. Used + /// by the skills platform extension to enumerate supporting resources + /// for an MCP-served skill without having to re-pack through the + /// stringified form in `list_resources_from_extension`. + pub async fn list_resources_for_server( + &self, + session_id: &str, + extension_name: &str, + cancellation_token: CancellationToken, + ) -> Result { + let client = self.get_server_client(extension_name).await.ok_or_else(|| { + ErrorData::new( + ErrorCode::INVALID_PARAMS, + format!("Extension '{}' not found", extension_name), + None, + ) + })?; + + client + .list_resources(session_id, None, cancellation_token) + .await + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Unable to list resources for {}: {:?}", extension_name, e), + None, + ) + }) + } + pub async fn read_resource( &self, session_id: &str, @@ -1842,7 +2008,8 @@ mod tests { bundled: None, available_tools, }; - let extension = Extension::new(config.clone(), config.clone(), client, None, None); + let extension = + Extension::new(config.clone(), config.clone(), client, None, None, Vec::new()); self.extensions .lock() .await @@ -2348,6 +2515,7 @@ mod tests { Arc::new(MockClient {}), None, None, + None, ) .await; assert_eq!(em.extensions.lock().await.len(), 1); @@ -2394,6 +2562,7 @@ mod tests { Arc::new(MockClient {}), None, None, + None, ) .await; assert_eq!(em.extensions.lock().await.len(), 1); diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index aa604d2dd393..abf33527eef0 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -101,6 +101,15 @@ pub trait McpClientTrait: Send + Sync { None } + /// Optional per-turn dynamic addition to the extension's instructions. + /// Returned text is appended to the static `InitializeResult.instructions` + /// when `ExtensionManager::get_extensions_info` assembles the system + /// prompt. Called on every reply, so implementations MUST NOT do network + /// I/O inline — read from caches instead. Default: no dynamic contribution. + async fn get_dynamic_instructions(&self, _session_id: &str) -> Option { + None + } + async fn update_working_dir(&self, _new_dir: PathBuf) -> Result<(), Error> { Ok(()) } @@ -356,6 +365,16 @@ impl ClientHandler for GooseClient { ); } + // Advertise host-side support for the skills-over-MCP SEP + // (`io.modelcontextprotocol/skills`). The SEP does not require + // clients to declare this; we do so informationally so servers + // can branch on "client understands skills" if they ever need + // to. Empty object per SEP §Capability Declaration. + extensions.insert( + crate::skills::mcp_client::SKILLS_EXTENSION_ID.to_string(), + JsonObject::new(), + ); + InitializeRequestParams::new( ClientCapabilities::builder() .enable_roots() @@ -1000,6 +1019,28 @@ mod tests { ); } + #[test] + fn test_client_capabilities_advertise_skills_extension() { + // Both CLI and Desktop platforms should advertise the skills SEP + // extension — it's a host-level capability, not platform-specific. + for platform in [GoosePlatform::GooseCli, GoosePlatform::GooseDesktop] { + let client = new_client(platform.clone()); + let info = ClientHandler::get_info(&client); + let extensions = info + .capabilities + .extensions + .as_ref() + .expect("client capabilities should include an extensions map"); + assert!( + extensions.contains_key( + crate::skills::mcp_client::SKILLS_EXTENSION_ID + ), + "client ({:?}) should advertise io.modelcontextprotocol/skills", + platform + ); + } + } + #[test] fn test_working_dir_roots_returns_current_dir_as_root() { let dir = PathBuf::from("/tmp/test-project"); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 1b41a743182b..a221907e84ba 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,5 +1,4 @@ mod agent; -pub(crate) mod builtin_skills; pub mod container; pub mod execute_commands; pub mod extension; diff --git a/crates/goose/src/agents/platform_extensions/developer/edit.rs b/crates/goose/src/agents/platform_extensions/developer/edit.rs index 2ab34dc789cb..5d14eae233e6 100644 --- a/crates/goose/src/agents/platform_extensions/developer/edit.rs +++ b/crates/goose/src/agents/platform_extensions/developer/edit.rs @@ -43,6 +43,9 @@ impl EditTools { params: FileReadParams, working_dir: Option<&Path>, ) -> CallToolResult { + if let Some(err) = reject_uri_path(¶ms.path) { + return err; + } let path = resolve_path(¶ms.path, working_dir); match fs::read_to_string(&path) { @@ -67,6 +70,9 @@ impl EditTools { params: FileWriteParams, working_dir: Option<&Path>, ) -> CallToolResult { + if let Some(err) = reject_uri_path(¶ms.path) { + return err; + } let path = resolve_path(¶ms.path, working_dir); if let Some(parent) = path.parent() { @@ -111,6 +117,9 @@ impl EditTools { params: FileEditParams, working_dir: Option<&Path>, ) -> CallToolResult { + if let Some(err) = reject_uri_path(¶ms.path) { + return err; + } let path = resolve_path(¶ms.path, working_dir); let content = match fs::read_to_string(&path) { @@ -225,6 +234,27 @@ pub fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf { } } +/// Guards file-reading / editing paths against URIs being passed in. +/// +/// Hosts that expose both a filesystem reader and an MCP resource reader +/// can trip over `skill://…`, `github://…`, and other scheme-prefixed +/// strings: the model sometimes tries the filesystem reader on them, and +/// `PathBuf::from` silently produces a bogus relative path under cwd. +/// See `Skills SEP host implementation guidelines.md` for the recorded +/// pitfall. Detect the `://` shape and redirect the model toward +/// `read_resource` / `load_skill` with a clear error. +pub fn reject_uri_path(path: &str) -> Option { + if !crate::agents::platform_extensions::looks_like_uri(path) { + return None; + } + + Some(CallToolResult::error(vec![Content::text(format!( + "'{}' is an MCP resource URI, not a filesystem path. Use the read_resource tool (it takes server + uri) for raw URIs, or load_skill for named skills.", + path + )) + .with_priority(0.0)])) +} + fn count_lines_before(content: &str, byte_pos: usize) -> usize { content .char_indices() @@ -297,6 +327,30 @@ mod tests { } } + #[test] + fn test_reject_uri_path_rejects_schemed_inputs() { + // Recognizes the canonical `skill://` scheme as well as domain- + // native schemes that the SEP explicitly permits. + assert!(reject_uri_path("skill://pull-requests/SKILL.md").is_some()); + assert!(reject_uri_path("github://owner/repo/skills/x/SKILL.md").is_some()); + assert!(reject_uri_path("repo://foo").is_some()); + assert!(reject_uri_path("https://example.com/a").is_some()); + // RFC 3986 allows +, -, . in scheme; all should be recognized. + assert!(reject_uri_path("svn+ssh://host/path").is_some()); + } + + #[test] + fn test_reject_uri_path_passes_through_filesystem_paths() { + assert!(reject_uri_path("/absolute/path").is_none()); + assert!(reject_uri_path("C:\\Users\\me\\file.txt").is_none()); + assert!(reject_uri_path("relative/path.md").is_none()); + // A literal colon in the name is not enough — we require `://`. + assert!(reject_uri_path("my:file").is_none()); + // `://` without a valid scheme prefix is passed through. + assert!(reject_uri_path("://foo").is_none()); + assert!(reject_uri_path("1bad://foo").is_none()); + } + #[test_case(None, None, "line1\nline2\nline3" ; "full content")] #[test_case(Some(2), None, "line2\nline3" ; "from line 2")] #[test_case(None, Some(2), "line1\nline2\n" ; "limit 2")] diff --git a/crates/goose/src/agents/platform_extensions/developer/mod.rs b/crates/goose/src/agents/platform_extensions/developer/mod.rs index b6cee198e205..8fcb90f2d01c 100644 --- a/crates/goose/src/agents/platform_extensions/developer/mod.rs +++ b/crates/goose/src/agents/platform_extensions/developer/mod.rs @@ -15,7 +15,7 @@ use rmcp::model::{ }; use schemars::{schema_for, JsonSchema}; use serde_json::Value; -use shell::{ShellOutput, ShellParams, ShellTool}; +use shell::{shell_display_name, ShellOutput, ShellParams, ShellTool}; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tree::{TreeParams, TreeTool}; @@ -120,7 +120,14 @@ impl DeveloperClient { )), Tool::new( "shell".to_string(), - "Execute a shell command in the user's default shell in the current dir. Returns an object with stdout and stderr as separate fields. The output of each stream is limited to up to 2000 lines, and longer outputs will be saved to a temporary file.".to_string(), + format!( + "Execute a shell command in the current dir. Commands run under `{shell}` \ + (set GOOSE_SHELL to override) - write command strings in that shell's \ + syntax. Returns an object with stdout and stderr as separate fields. The \ + output of each stream is limited to up to 2000 lines, and longer outputs \ + will be saved to a temporary file.", + shell = shell_display_name(), + ), Self::schema::(), ) .with_output_schema::() diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index 9a74f8047ae8..49ac6f111259 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -47,50 +47,67 @@ fn flatpak_spawn_process() -> std::process::Command { command } -/// Resolve the preferred Unix shell, respecting GOOSE_SHELL. +/// Resolve the preferred Unix shell for command execution, respecting GOOSE_SHELL. /// -/// Returns `(shell_path, is_user_configured)` — the boolean is true when -/// `GOOSE_SHELL` was explicitly set, which matters in Flatpak mode: an -/// explicit path is passed through as-is (the user likely intends a host -/// path), whereas auto-detected defaults are reduced to their basename so -/// the host's PATH resolves the correct binary. +/// Auto-detected shells are returned as basenames (e.g. `"bash"`) so that +/// `Command::new` resolves them on `PATH` at spawn time — this also keeps +/// Flatpak happy, where absolute paths from inside the sandbox don't match +/// the host filesystem. `GOOSE_SHELL` is passed through as-is. /// -/// In Flatpak mode without an explicit GOOSE_SHELL, we skip sandbox -/// filesystem checks (e.g. `/bin/bash` existing inside the sandbox tells -/// us nothing about the host) and default to `"bash"` directly. +#[cfg(windows)] +fn windows_shell() -> String { + std::env::var("GOOSE_SHELL").unwrap_or_else(|_| "cmd".to_string()) +} + +/// Short, human-readable name of a shell path (the file stem), used both to +/// pick the right argument style on Windows and to tell the LLM which +/// dialect to write in the tool description. +#[cfg(windows)] +fn shell_basename(shell: &str) -> String { + Path::new(shell) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("cmd") + .to_lowercase() +} + #[cfg(not(windows))] -fn unix_shell() -> (String, bool) { - match std::env::var("GOOSE_SHELL") { - Ok(shell) => (shell, true), - Err(_) => { - let shell = if is_flatpak() { - // Don't inspect sandbox paths — they don't reflect the host. - // Default to "bash" (ubiquitous on Flatpak-capable Linux hosts). - "bash".to_string() - } else if PathBuf::from("/bin/bash").is_file() { - "/bin/bash".to_string() - } else { - std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()) - }; - (shell, false) - } - } +fn shell_basename(shell: &str) -> String { + std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell) + .to_string() +} + +/// Basename of the shell the `shell` tool will invoke, for use in the tool +/// description so the LLM knows which dialect to write. +#[cfg(windows)] +pub fn shell_display_name() -> String { + shell_basename(&windows_shell()) } -/// Return the shell reference to pass to `flatpak-spawn --host`. -/// -/// If the user explicitly configured GOOSE_SHELL, honour the full path -/// (it likely refers to a host binary, e.g. a Nix-profile shell). -/// Otherwise strip to basename so the host's default PATH resolves it. #[cfg(not(windows))] -fn flatpak_shell_arg(shell: &str, is_user_configured: bool) -> &str { - if is_user_configured { - shell +pub fn shell_display_name() -> String { + shell_basename(&unix_shell()) +} + +/// The shell tool runs commands with `-c "..."`, and LLMs routinely emit +/// POSIX-style patterns such as heredocs (`cat < file`), `$VAR` +/// expansion, and `2>&1` redirection. Non-POSIX shells (fish, csh, tcsh, +/// nu, ...) reject or mis-interpret these, so we don't auto-select based +/// on `$SHELL`: we check whether `bash` is on PATH and otherwise fall back +/// to `sh`. Users who really want their login shell can opt in via +/// `GOOSE_SHELL`. +#[cfg(not(windows))] +fn unix_shell() -> String { + if let Ok(shell) = std::env::var("GOOSE_SHELL") { + return shell; + } + if which::which("bash").is_ok() { + "bash".to_string() } else { - std::path::Path::new(shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("bash") + "sh".to_string() } } @@ -149,17 +166,11 @@ pub struct ShellOutput { /// source the user's profile and recover the full PATH. #[cfg(not(windows))] fn resolve_login_shell_path() -> Option { - let (shell, is_user_configured) = unix_shell(); + let shell = unix_shell(); let mut child = if is_flatpak() { flatpak_spawn_process() - .args([ - flatpak_shell_arg(&shell, is_user_configured), - "-l", - "-i", - "-c", - "echo $PATH", - ]) + .args([&shell, "-l", "-i", "-c", "echo $PATH"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -540,12 +551,8 @@ fn build_shell_command( ) -> tokio::process::Command { #[cfg(windows)] let mut command = { - let shell = std::env::var("GOOSE_SHELL").unwrap_or_else(|_| "cmd".to_string()); - let shell_stem = Path::new(&shell) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("cmd") - .to_lowercase(); + let shell = windows_shell(); + let shell_stem = shell_basename(&shell); let mut command = tokio::process::Command::new(&shell); match shell_stem.as_str() { "pwsh" | "powershell" => { @@ -570,7 +577,7 @@ fn build_shell_command( #[cfg(not(windows))] let mut command = { - let (shell, is_user_configured) = unix_shell(); + let shell = unix_shell(); if is_flatpak() { let mut command = flatpak_spawn_command(); @@ -580,12 +587,7 @@ fn build_shell_command( if let Some(path) = login_path { command.arg(format!("--env=PATH={}", path)); } - // If GOOSE_SHELL was explicitly set, honour the full path (likely a host - // binary). Otherwise use basename so the host's PATH resolves it. - command - .arg(flatpak_shell_arg(&shell, is_user_configured)) - .arg("-c") - .arg(command_line); + command.arg(&shell).arg("-c").arg(command_line); command } else { let mut command = tokio::process::Command::new(shell); diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index 2166d3f3f715..fdf726a10b76 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -6,73 +6,36 @@ pub mod code_execution; pub mod developer; pub mod ext_manager; pub mod orchestrator; -pub mod skills; pub mod summarize; pub mod summon; pub mod todo; pub mod tom; use std::collections::HashMap; -use std::path::PathBuf; use crate::agents::mcp_client::McpClientTrait; use crate::session::Session; use once_cell::sync::Lazy; -use serde::Deserialize; -#[derive(Debug, Clone)] -pub struct Source { - pub name: String, - pub kind: SourceKind, - pub description: String, - pub path: PathBuf, - pub content: String, - pub supporting_files: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum SourceKind { - Subrecipe, - Recipe, - Skill, - Agent, - BuiltinSkill, -} - -impl std::fmt::Display for SourceKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SourceKind::Subrecipe => write!(f, "subrecipe"), - SourceKind::Recipe => write!(f, "recipe"), - SourceKind::Skill => write!(f, "skill"), - SourceKind::Agent => write!(f, "agent"), - SourceKind::BuiltinSkill => write!(f, "builtin skill"), - } - } -} - -impl Source { - pub fn to_load_text(&self) -> String { - format!( - "## {} ({})\n\n{}\n\n### Content\n\n{}", - self.name, self.kind, self.description, self.content - ) +/// Returns true if `s` starts with an RFC 3986 scheme followed by `://` +/// (e.g. `skill://foo`, `github://owner/repo`, `https://example.com`). +/// Narrow on purpose: POSIX paths can't match (no `://`) and well-formed +/// Windows absolute paths can't either (drive letter + `:\`). Shared by +/// `developer::edit::reject_uri_path` and the `load_skill` tool so the +/// two guardrails can't drift apart. +pub fn looks_like_uri(s: &str) -> bool { + let Some(colon_idx) = s.find("://") else { + return false; + }; + let scheme = &s[..colon_idx]; + let mut chars = scheme.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_alphabetic() { + return false; } -} - -pub fn parse_frontmatter Deserialize<'de>>( - content: &str, -) -> Result, serde_yaml::Error> { - let parts: Vec<&str> = content.split("---").collect(); - if parts.len() < 3 { - return Ok(None); - } - - let yaml_content = parts[1].trim(); - let metadata: T = serde_yaml::from_str(yaml_content)?; - - let body = parts[2..].join("---").trim().to_string(); - Ok(Some((metadata, body))) + chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') } pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; @@ -248,15 +211,15 @@ pub static PLATFORM_EXTENSIONS: Lazy ); map.insert( - skills::EXTENSION_NAME, + crate::skills::EXTENSION_NAME, PlatformExtensionDef { - name: skills::EXTENSION_NAME, + name: crate::skills::EXTENSION_NAME, display_name: "Skills", description: "Discover and provide skill instructions from filesystem and builtins", default_enabled: true, unprefixed_tools: true, hidden: false, - client_factory: |ctx| Box::new(skills::SkillsClient::new(ctx).unwrap()), + client_factory: |ctx| Box::new(crate::skills::SkillsClient::new(ctx).unwrap()), }, ); diff --git a/crates/goose/src/agents/platform_extensions/skills.rs b/crates/goose/src/agents/platform_extensions/skills.rs deleted file mode 100644 index c209cfea53be..000000000000 --- a/crates/goose/src/agents/platform_extensions/skills.rs +++ /dev/null @@ -1,506 +0,0 @@ -use super::{parse_frontmatter, Source, SourceKind}; -use crate::agents::builtin_skills; -use crate::agents::extension::PlatformExtensionContext; -use crate::agents::mcp_client::{Error, McpClientTrait}; -use crate::agents::tool_execution::ToolCallContext; -use crate::config::paths::Paths; -use async_trait::async_trait; -use rmcp::model::{ - CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, - ServerCapabilities, ServerNotification, Tool, -}; -use serde::Deserialize; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use tracing::warn; - -pub static EXTENSION_NAME: &str = "skills"; - -#[derive(Debug, Deserialize)] -struct SkillMetadata { - name: String, - description: String, -} - -fn parse_skill_content(content: &str, path: PathBuf) -> Option { - let (metadata, body): (SkillMetadata, String) = match parse_frontmatter(content) { - Ok(Some(parsed)) => parsed, - Ok(None) => return None, - Err(e) => { - warn!("Failed to parse skill frontmatter: {}", e); - return None; - } - }; - - if metadata.name.contains('/') { - warn!("Skill name '{}' contains '/', skipping", metadata.name); - return None; - } - - Some(Source { - name: metadata.name, - kind: SourceKind::Skill, - description: metadata.description, - path, - content: body, - supporting_files: Vec::new(), - }) -} - -fn should_skip_dir(path: &Path) -> bool { - matches!( - path.file_name().and_then(|name| name.to_str()), - Some(".git") | Some(".hg") | Some(".svn") - ) -} - -fn walk_files_recursively( - dir: &Path, - visited_dirs: &mut HashSet, - should_descend: &mut G, - visit_file: &mut F, -) where - F: FnMut(&Path), - G: FnMut(&Path) -> bool, -{ - let canonical_dir = match std::fs::canonicalize(dir) { - Ok(path) => path, - Err(_) => return, - }; - - if !visited_dirs.insert(canonical_dir) { - return; - } - - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if should_descend(&path) { - walk_files_recursively(&path, visited_dirs, should_descend, visit_file); - } - } else if path.is_file() { - visit_file(&path); - } - } -} - -fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet) -> Vec { - let mut skill_files = Vec::new(); - let mut visited_dirs = HashSet::new(); - - walk_files_recursively( - dir, - &mut visited_dirs, - &mut |path| !should_skip_dir(path), - &mut |path| { - if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") { - skill_files.push(path.to_path_buf()); - } - }, - ); - - let mut sources = Vec::new(); - for skill_file in skill_files { - let Some(skill_dir) = skill_file.parent() else { - continue; - }; - let content = match std::fs::read_to_string(&skill_file) { - Ok(c) => c, - Err(e) => { - warn!("Failed to read skill file {}: {}", skill_file.display(), e); - continue; - } - }; - - if let Some(mut source) = parse_skill_content(&content, skill_dir.to_path_buf()) { - if !seen.contains(&source.name) { - // Find supporting files in the skill directory - let mut files = Vec::new(); - let mut visited_support_dirs = HashSet::new(); - walk_files_recursively( - skill_dir, - &mut visited_support_dirs, - &mut |path| !should_skip_dir(path) && !path.join("SKILL.md").is_file(), - &mut |path| { - if path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") { - files.push(path.to_path_buf()); - } - }, - ); - source.supporting_files = files; - - seen.insert(source.name.clone()); - sources.push(source); - } - } - } - sources -} - -fn discover_skills(working_dir: &Path) -> Vec { - let mut sources = Vec::new(); - let mut seen = HashSet::new(); - - let home = dirs::home_dir(); - let config = Paths::config_dir(); - - let local_dirs = vec![ - working_dir.join(".goose/skills"), - working_dir.join(".claude/skills"), - working_dir.join(".agents/skills"), - ]; - - let global_dirs: Vec = [ - home.as_ref().map(|h| h.join(".agents/skills")), - Some(config.join("skills")), - home.as_ref().map(|h| h.join(".claude/skills")), - home.as_ref().map(|h| h.join(".config/agents/skills")), - ] - .into_iter() - .flatten() - .collect(); - - for dir in local_dirs { - sources.extend(scan_skills_from_dir(&dir, &mut seen)); - } - for dir in global_dirs { - sources.extend(scan_skills_from_dir(&dir, &mut seen)); - } - - for content in builtin_skills::get_all() { - if let Some(source) = parse_skill_content(content, PathBuf::new()) { - if !seen.contains(&source.name) { - seen.insert(source.name.clone()); - sources.push(Source { - kind: SourceKind::BuiltinSkill, - ..source - }); - } - } - } - - sources -} - -pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec { - let dir = working_dir - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - discover_skills(&dir) -} - -pub struct SkillsClient { - info: InitializeResult, - working_dir: PathBuf, -} - -impl SkillsClient { - pub fn new(context: PlatformExtensionContext) -> anyhow::Result { - let working_dir = context - .session - .as_ref() - .map(|s| s.working_dir.clone()) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - - let mut instructions = String::new(); - if context.session.is_some() { - let sources = discover_skills(&working_dir); - let mut skills: Vec<&Source> = sources - .iter() - .filter(|s| s.kind == SourceKind::Skill || s.kind == SourceKind::BuiltinSkill) - .collect(); - skills.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path))); - - if !skills.is_empty() { - instructions.push_str( - "\n\nYou have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:", - ); - for skill in &skills { - instructions.push_str(&format!("\n• {} - {}", skill.name, skill.description)); - } - } - } - - let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Skills")) - .with_instructions(instructions); - - Ok(Self { info, working_dir }) - } -} - -#[async_trait] -impl McpClientTrait for SkillsClient { - async fn list_tools( - &self, - _session_id: &str, - _next_cursor: Option, - _cancellation_token: CancellationToken, - ) -> Result { - let schema = serde_json::json!({ - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "description": "Name of the skill to load. Use \"skill-name/path\" to load a supporting file." - } - } - }); - - let tool = Tool::new( - "load_skill", - "Load a skill's full content into your context so you can follow its instructions.\n\n\ - Skills are listed in your system instructions. When you need to use one, \ - load it first to get the detailed instructions.\n\n\ - Examples:\n\ - - load_skill(name: \"gdrive\") → Loads the gdrive skill instructions\n\ - - load_skill(name: \"my-skill/template.md\") → Loads a supporting file" - .to_string(), - schema.as_object().unwrap().clone(), - ); - - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) - } - - async fn call_tool( - &self, - _ctx: &ToolCallContext, - name: &str, - arguments: Option, - _cancellation_token: CancellationToken, - ) -> Result { - if name != "load_skill" { - return Ok(CallToolResult::error(vec![Content::text(format!( - "Unknown tool: {}", - name - ))])); - } - - let skill_name = arguments - .as_ref() - .and_then(|args| args.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if skill_name.is_empty() { - return Ok(CallToolResult::error(vec![Content::text( - "Missing required parameter: name", - )])); - } - - let skills = discover_skills(&self.working_dir); - - // Direct skill match - if let Some(skill) = skills.iter().find(|s| s.name == skill_name) { - let mut output = format!( - "# Loaded Skill: {} ({})\n\n{}\n", - skill.name, - skill.kind, - skill.to_load_text() - ); - - if !skill.supporting_files.is_empty() { - output.push_str(&format!( - "\n## Supporting Files\n\nSkill directory: {}\n\n", - skill.path.display() - )); - for file in &skill.supporting_files { - if let Ok(relative) = file.strip_prefix(&skill.path) { - let rel_str = relative.to_string_lossy().replace('\\', "/"); - output.push_str(&format!( - "- {} → load_skill(name: \"{}/{}\")\n", - rel_str, skill.name, rel_str - )); - } - } - } - - output.push_str("\n---\nThis knowledge is now available in your context."); - return Ok(CallToolResult::success(vec![Content::text(output)])); - } - - // Supporting file match (skill_name contains '/') - if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') { - let relative_path = raw_relative_path.replace('\\', "/"); - if let Some(skill) = skills.iter().find(|s| { - s.name == parent_skill_name - && matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill) - }) { - let canonical_skill_dir = skill - .path - .canonicalize() - .unwrap_or_else(|_| skill.path.clone()); - - for file_path in &skill.supporting_files { - let Ok(rel) = file_path.strip_prefix(&skill.path) else { - continue; - }; - if rel.to_string_lossy().replace('\\', "/") != relative_path { - continue; - } - - return Ok(match file_path.canonicalize() { - Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => { - match std::fs::read_to_string(&canonical) { - Ok(content) => { - CallToolResult::success(vec![Content::text(format!( - "# Loaded: {}\n\n{}\n\n---\nFile loaded into context.", - skill_name, content - ))]) - } - Err(e) => CallToolResult::error(vec![Content::text(format!( - "Failed to read '{}': {}", - skill_name, e - ))]), - } - } - Ok(_) => CallToolResult::error(vec![Content::text(format!( - "Refusing to load '{}': resolves outside the skill directory", - skill_name - ))]), - Err(e) => CallToolResult::error(vec![Content::text(format!( - "Failed to resolve '{}': {}", - skill_name, e - ))]), - }); - } - - let available: Vec = skill - .supporting_files - .iter() - .filter_map(|f| { - f.strip_prefix(&skill.path) - .ok() - .map(|r| r.to_string_lossy().replace('\\', "/")) - }) - .take(10) - .collect(); - - return Ok(if available.is_empty() { - CallToolResult::error(vec![Content::text(format!( - "Skill '{}' has no supporting files.", - skill.name - ))]) - } else { - CallToolResult::error(vec![Content::text(format!( - "File '{}' not found. Available: {}", - skill_name, - available.join(", ") - ))]) - }); - } - } - - // No match — suggest similar skills - let suggestions: Vec<&str> = skills - .iter() - .filter(|s| { - s.name.to_lowercase().contains(&skill_name.to_lowercase()) - || skill_name.to_lowercase().contains(&s.name.to_lowercase()) - }) - .take(3) - .map(|s| s.name.as_str()) - .collect(); - - Ok(if suggestions.is_empty() { - CallToolResult::error(vec![Content::text(format!( - "Skill '{}' not found.", - skill_name - ))]) - } else { - CallToolResult::error(vec![Content::text(format!( - "Skill '{}' not found. Did you mean: {}?", - skill_name, - suggestions.join(", ") - ))]) - }) - } - - fn get_info(&self) -> Option<&InitializeResult> { - Some(&self.info) - } - - async fn subscribe(&self) -> mpsc::Receiver { - let (_tx, rx) = mpsc::channel(1); - rx - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::sync::Arc; - use tempfile::TempDir; - - #[tokio::test] - async fn test_load_skill_from_filesystem() { - let temp_dir = TempDir::new().unwrap(); - let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); - fs::create_dir_all(&skill_dir).unwrap(); - fs::write( - skill_dir.join("SKILL.md"), - "---\nname: my-skill\ndescription: A test skill\n---\nDo the thing.", - ) - .unwrap(); - - let session = std::sync::Arc::new(crate::session::Session { - working_dir: temp_dir.path().to_path_buf(), - ..crate::session::Session::default() - }); - let client = SkillsClient::new(PlatformExtensionContext { - extension_manager: None, - session_manager: Arc::new(crate::session::SessionManager::instance()), - session: Some(session), - }) - .unwrap(); - - let ctx = ToolCallContext::new("test".to_string(), None, None); - let args: JsonObject = - serde_json::from_value(serde_json::json!({"name": "my-skill"})).unwrap(); - let result = client - .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) - .await - .unwrap(); - - assert!(!result.is_error.unwrap_or(false)); - let text = match &result.content[0].raw { - rmcp::model::RawContent::Text(t) => &t.text, - _ => panic!("expected text"), - }; - assert!(text.contains("my-skill")); - assert!(text.contains("Do the thing")); - } - - #[tokio::test] - async fn test_load_skill_not_found_returns_error() { - let client = SkillsClient::new(PlatformExtensionContext { - extension_manager: None, - session_manager: Arc::new(crate::session::SessionManager::instance()), - session: None, - }) - .unwrap(); - - let ctx = ToolCallContext::new("test".to_string(), None, None); - let args: JsonObject = - serde_json::from_value(serde_json::json!({"name": "nonexistent"})).unwrap(); - let result = client - .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) - .await - .unwrap(); - - assert!(result.is_error.unwrap_or(false)); - } -} diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 8badb85793bf..68eb229e90ee 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -1,4 +1,3 @@ -use super::{parse_frontmatter, Source, SourceKind}; use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams}; @@ -13,8 +12,10 @@ use crate::recipe::local_recipes::load_local_recipe_file; use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS}; use crate::session::extension_data::EnabledExtensionsState; use crate::session::SessionType; +use crate::sources::parse_frontmatter; use anyhow::Result; use async_trait::async_trait; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta, ServerCapabilities, ServerNotification, Tool, @@ -33,11 +34,11 @@ use tracing::{info, warn}; pub static EXTENSION_NAME: &str = "summon"; -fn kind_plural(kind: SourceKind) -> &'static str { +fn kind_plural(kind: SourceType) -> &'static str { match kind { - SourceKind::Subrecipe => "Subrecipes", - SourceKind::Recipe => "Recipes", - SourceKind::Agent => "Agents", + SourceType::Subrecipe => "Subrecipes", + SourceType::Recipe => "Recipes", + SourceType::Agent => "Agents", _ => "Other", } } @@ -95,7 +96,7 @@ struct AgentMetadata { model: Option, } -fn parse_agent_content(content: &str, path: &Path) -> Option { +fn parse_agent_content(content: &str, path: &Path) -> Option { let (metadata, body): (AgentMetadata, String) = match parse_frontmatter(content) { Ok(Some(parsed)) => parsed, Ok(None) => return None, @@ -119,20 +120,21 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { format!("Agent{}", model_info) }); - Some(Source { + Some(SourceEntry { + source_type: SourceType::Agent, name: metadata.name, - kind: SourceKind::Agent, description, - path: path.to_path_buf(), content: body, + directory: path.to_string_lossy().into_owned(), + global: false, supporting_files: Vec::new(), }) } fn scan_recipes_from_dir( dir: &Path, - kind: SourceKind, - sources: &mut Vec, + kind: SourceType, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let entries = match std::fs::read_dir(dir) { @@ -164,12 +166,13 @@ fn scan_recipes_from_dir( match Recipe::from_file_path(&path) { Ok(recipe) => { seen.insert(name.clone()); - sources.push(Source { + sources.push(SourceEntry { + source_type: kind, name, - kind, description: recipe.description.clone(), - path: path.clone(), content: recipe.instructions.clone().unwrap_or_default(), + directory: path.to_string_lossy().into_owned(), + global: false, supporting_files: Vec::new(), }); } @@ -182,7 +185,7 @@ fn scan_recipes_from_dir( fn scan_agents_from_dir( dir: &Path, - sources: &mut Vec, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let entries = match std::fs::read_dir(dir) { @@ -218,8 +221,8 @@ fn scan_agents_from_dir( } } -fn discover_filesystem_sources(working_dir: &Path) -> Vec { - let mut sources: Vec = Vec::new(); +pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { + let mut sources: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let home = dirs::home_dir(); @@ -240,6 +243,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { }) .chain( [ + home.as_ref().map(|h| h.join(".goose/recipes")), Some(config.join("recipes")), home.as_ref().map(|h| h.join(".agents/recipes")), ] @@ -255,6 +259,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { ]; let global_agent_dirs: Vec = [ + home.as_ref().map(|h| h.join(".goose/agents")), home.as_ref().map(|h| h.join(".agents/agents")), Some(config.join("agents")), home.as_ref().map(|h| h.join(".claude/agents")), @@ -264,7 +269,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { .collect(); for dir in local_recipe_dirs { - scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen); + scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen); } for dir in local_agent_dirs { @@ -272,7 +277,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { } for dir in global_recipe_dirs { - scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen); + scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen); } for dir in global_agent_dirs { @@ -313,7 +318,7 @@ fn is_session_id(s: &str) -> bool { pub struct SummonClient { info: InitializeResult, context: PlatformExtensionContext, - source_cache: Mutex)>>, + source_cache: Mutex)>>, background_tasks: Mutex>, completed_tasks: Mutex>, notification_subscribers: Arc>>>, @@ -475,11 +480,11 @@ impl SummonClient { .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) } - async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec { + async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec { let fs_sources = self.get_filesystem_sources(working_dir).await; let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - let mut sources: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); self.add_subrecipes(session_id, &mut sources, &mut seen) .await; @@ -491,11 +496,11 @@ impl SummonClient { } } - sources.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name))); + sources.sort_by(|a, b| (&a.source_type, &a.name).cmp(&(&b.source_type, &b.name))); sources } - async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec { + async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec { let mut cache = self.source_cache.lock().await; if let Some((cached_at, cached_dir, sources)) = cache.as_ref() { if cached_dir == working_dir && cached_at.elapsed() < Duration::from_secs(60) { @@ -512,11 +517,11 @@ impl SummonClient { session_id: &str, name: &str, working_dir: &Path, - ) -> Result, String> { + ) -> Result, String> { let sources = self.get_sources(session_id, working_dir).await; if let Some(mut source) = sources.iter().find(|s| s.name == name).cloned() { - if source.kind == SourceKind::Subrecipe && source.content.is_empty() { + if source.source_type == SourceType::Subrecipe && source.content.is_empty() { source.content = self.load_subrecipe_content(session_id, &source.name).await; } return Ok(Some(source)); @@ -555,14 +560,14 @@ impl SummonClient { } } - fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec { + fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec { discover_filesystem_sources(working_dir) } async fn add_subrecipes( &self, session_id: &str, - sources: &mut Vec, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let session = match self @@ -588,12 +593,13 @@ impl SummonClient { let description = self.build_subrecipe_description(sr).await; - sources.push(Source { + sources.push(SourceEntry { + source_type: SourceType::Subrecipe, name: sr.name.clone(), - kind: SourceKind::Subrecipe, description, - path: PathBuf::from(&sr.path), content: String::new(), + directory: sr.path.clone(), + global: false, supporting_files: Vec::new(), }); } @@ -839,8 +845,8 @@ impl SummonClient { } } - for kind in [SourceKind::Subrecipe, SourceKind::Recipe, SourceKind::Agent] { - let kind_sources: Vec<_> = sources.iter().filter(|s| s.kind == kind).collect(); + for kind in [SourceType::Subrecipe, SourceType::Recipe, SourceType::Agent] { + let kind_sources: Vec<_> = sources.iter().filter(|s| s.source_type == kind).collect(); if !kind_sources.is_empty() { output.push_str(&format!("\n{}:\n", kind_plural(kind))); for source in kind_sources { @@ -873,7 +879,7 @@ impl SummonClient { let output = format!( "# Loaded: {} ({})\n\n{}\n\n---\nThis knowledge is now available in your context.", - source.name, source.kind, content + source.name, source.source_type, content ); Ok(vec![Content::text(output)]) @@ -1078,16 +1084,16 @@ impl SummonClient { .await? .ok_or_else(|| format!("Source '{}' not found", source_name))?; - let mut recipe = match source.kind { - SourceKind::Recipe | SourceKind::Subrecipe => { + let mut recipe = match source.source_type { + SourceType::Recipe | SourceType::Subrecipe => { self.build_recipe_from_source(&source, params, session_id) .await? } - SourceKind::Agent => self.build_recipe_from_agent(&source, params)?, + SourceType::Agent => self.build_recipe_from_agent(&source, params)?, _ => { return Err(format!( "Source '{}' has kind '{}' which cannot be delegated from summon", - source_name, source.kind + source_name, source.source_type )) } }; @@ -1106,7 +1112,7 @@ impl SummonClient { async fn build_recipe_from_source( &self, - source: &Source, + source: &SourceEntry, params: &DelegateParams, session_id: &str, ) -> Result { @@ -1117,7 +1123,7 @@ impl SummonClient { .await .map_err(|e| format!("Failed to get session: {}", e))?; - if source.kind == SourceKind::Subrecipe { + if source.source_type == SourceType::Subrecipe { let sub_recipes = session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref()); if let Some(sub_recipes) = sub_recipes { @@ -1154,7 +1160,7 @@ impl SummonClient { } } - let recipe_file = load_local_recipe_file(source.path.to_str().unwrap_or("")) + let recipe_file = load_local_recipe_file(&source.directory) .map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?; let param_values: Vec<(String, String)> = params @@ -1184,13 +1190,13 @@ impl SummonClient { fn build_recipe_from_agent( &self, - source: &Source, + source: &SourceEntry, params: &DelegateParams, ) -> Result { - let agent_content = if source.path.as_os_str().is_empty() { + let agent_content = if source.directory.is_empty() { return Err("Agent source has no path".to_string()); } else { - std::fs::read_to_string(&source.path) + std::fs::read_to_string(&source.directory) .map_err(|e| format!("Failed to read agent file: {}", e))? }; @@ -1745,14 +1751,14 @@ You review code."#; let recipe = sources .iter() - .find(|s| s.name == "deploy" && s.kind == SourceKind::Recipe) + .find(|s| s.name == "deploy" && s.source_type == SourceType::Recipe) .unwrap(); assert_eq!(recipe.description, "Deploy to production"); assert_eq!(recipe.content, "Run deploy steps"); let agent = sources .iter() - .find(|s| s.name == "reviewer" && s.kind == SourceKind::Agent) + .find(|s| s.name == "reviewer" && s.source_type == SourceType::Agent) .unwrap(); assert_eq!(agent.description, "Code reviewer"); assert!(agent.content.contains("You review code")); diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index dae26e70b658..b6ae713cbfb9 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -416,6 +416,11 @@ mod tests { use std::sync::Arc; let tmp_dir = tempfile::tempdir().unwrap(); + let temp_root = tmp_dir.path().display().to_string(); + let _guard = env_lock::lock_env([ + ("HOME", Some(temp_root.as_str())), + ("GOOSE_PATH_ROOT", Some(temp_root.as_str())), + ]); let session_manager = Arc::new(SessionManager::new(tmp_dir.path().to_path_buf())); let session = session_manager .create_session( diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 9b39b9d62024..a6a9ee0289f4 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -212,7 +212,7 @@ impl Agent { // Prepare system prompt let extensions_info = self .extension_manager - .get_extensions_info(working_dir) + .get_extensions_info(session_id, working_dir) .await; let (extension_count, tool_count) = self .extension_manager diff --git a/crates/goose-acp/src/bin/generate_acp_schema.rs b/crates/goose/src/bin/generate_acp_schema.rs similarity index 99% rename from crates/goose-acp/src/bin/generate_acp_schema.rs rename to crates/goose/src/bin/generate_acp_schema.rs index e50afdaa8b77..070d9d74e7fa 100644 --- a/crates/goose-acp/src/bin/generate_acp_schema.rs +++ b/crates/goose/src/bin/generate_acp_schema.rs @@ -1,4 +1,4 @@ -use goose_acp::server::GooseAcpAgent; +use goose::acp::server::GooseAcpAgent; use schemars::SchemaGenerator; use serde_json::{json, Map, Value}; use std::collections::{BTreeSet, HashMap}; diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index e470c0a4e3bd..b8a1dc58a550 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -2,6 +2,7 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::providers::anthropic::AnthropicProvider; use crate::providers::base::{ModelInfo, ProviderType}; +use crate::providers::inventory::declarative_inventory_identity; use crate::providers::ollama::OllamaProvider; use crate::providers::openai::OpenAiProvider; use anyhow::Result; @@ -76,6 +77,10 @@ pub struct DeclarativeProviderConfig { #[serde(default)] pub skip_canonical_filtering: bool, #[serde(default, deserialize_with = "deserialize_non_empty_string")] + pub model_doc_link: Option, + #[serde(default)] + pub setup_steps: Vec, + #[serde(default, deserialize_with = "deserialize_non_empty_string")] pub fast_model: Option, } @@ -232,6 +237,8 @@ pub fn create_custom_provider( env_vars: None, dynamic_models: None, skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], fast_model: None, }; @@ -299,6 +306,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> env_vars: existing_config.env_vars, dynamic_models: existing_config.dynamic_models, skip_canonical_filtering: existing_config.skip_canonical_filtering, + model_doc_link: existing_config.model_doc_link, + setup_steps: existing_config.setup_steps, fast_model: existing_config.fast_model.clone(), }; @@ -460,38 +469,59 @@ pub fn register_declarative_provider( match config.engine { ProviderEngine::OpenAI => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; OpenAiProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } ProviderEngine::Ollama => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; OllamaProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } ProviderEngine::Anthropic => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; AnthropicProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } } @@ -565,6 +595,33 @@ mod tests { serde_json::from_str(json).expect("groq.json should parse without env_vars"); assert!(config.env_vars.is_none()); assert!(config.dynamic_models.is_none()); + assert!(config.model_doc_link.is_none()); + assert!(config.setup_steps.is_empty()); + } + + #[test] + fn test_nvidia_json_deserializes() { + let json = include_str!("../providers/declarative/nvidia.json"); + let config: DeclarativeProviderConfig = + serde_json::from_str(json).expect("nvidia.json should parse"); + assert_eq!(config.name, "nvidia"); + assert_eq!(config.display_name, "NVIDIA"); + assert!(matches!(config.engine, ProviderEngine::OpenAI)); + assert_eq!(config.api_key_env, "NVIDIA_API_KEY"); + assert_eq!(config.base_url, "https://integrate.api.nvidia.com/v1"); + assert_eq!(config.catalog_provider_id, Some("nvidia".to_string())); + assert_eq!(config.dynamic_models, Some(true)); + assert_eq!(config.supports_streaming, Some(true)); + assert!(!config.skip_canonical_filtering); + assert_eq!( + config.model_doc_link, + Some("https://build.nvidia.com/models".to_string()) + ); + assert_eq!(config.setup_steps.len(), 4); + + assert_eq!(config.models.len(), 1); + assert_eq!(config.models[0].name, "z-ai/glm-4.7"); + assert_eq!(config.models[0].context_limit, 131072); } #[test] diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index cd60b4de8ee7..ab64d11ca496 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -5,6 +5,7 @@ compile_error!("At least one of `rustls-tls` or `native-tls` features must be en compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive"); pub mod acp; +pub use goose_sdk::custom_requests; pub mod action_required_manager; pub mod agents; pub mod builtin_extension; @@ -37,7 +38,9 @@ pub mod scheduler_trait; pub mod security; pub mod session; pub mod session_context; +pub mod skills; pub mod slash_commands; +pub mod sources; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index ab1d76bd9777..eebace1aa8d0 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -138,14 +138,31 @@ impl ModelConfig { } } - if let Some(canonical) = + // Try canonical lookup with the full model name first, then fall back + // to the name with reasoning-effort suffixes stripped (e.g. + // "databricks-gpt-5.4-high" → "databricks-gpt-5.4"). + let canonical = crate::providers::canonical::maybe_get_canonical_model(provider_name, &self.model_name) - { + .or_else(|| { + let (base, _effort) = + crate::providers::utils::extract_reasoning_effort(&self.model_name); + if base != self.model_name { + crate::providers::canonical::maybe_get_canonical_model(provider_name, &base) + } else { + None + } + }); + + if let Some(canonical) = canonical { if self.context_limit.is_none() { self.context_limit = Some(canonical.limit.context); } if self.max_tokens.is_none() { - self.max_tokens = canonical.limit.output.map(|o| o as i32); + self.max_tokens = canonical + .limit + .output + .filter(|&output| output < canonical.limit.context) + .map(|output| output as i32); } if self.reasoning.is_none() { self.reasoning = canonical.reasoning; @@ -299,15 +316,7 @@ impl ModelConfig { } pub fn is_openai_reasoning_model(&self) -> bool { - const DATABRICKS_MODEL_NAME_PREFIXES: &[&str] = &["goose-", "databricks-"]; - const REASONING_PREFIXES: &[&str] = &["o1", "o3", "o4", "gpt-5"]; - - let base = DATABRICKS_MODEL_NAME_PREFIXES - .iter() - .find_map(|p| self.model_name.strip_prefix(p)) - .unwrap_or(&self.model_name); - - REASONING_PREFIXES.iter().any(|p| base.starts_with(p)) + crate::providers::utils::is_openai_responses_model(&self.model_name) } pub fn max_output_tokens(&self) -> i32 { @@ -486,6 +495,20 @@ mod tests { assert_eq!(config.max_tokens, Some(1_000)); } + #[test] + fn skips_canonical_output_limit_when_it_equals_context_limit() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = + ModelConfig::new_or_fail("moonshotai/kimi-k2.5").with_canonical_limits("nvidia"); + + assert_eq!(config.context_limit, Some(262_144)); + assert_eq!(config.max_tokens, None); + assert_eq!(config.max_output_tokens(), 4_096); + } + #[test] fn unknown_model_leaves_fields_none() { let _guard = env_lock::lock_env([ @@ -499,6 +522,28 @@ mod tests { assert_eq!(config.max_tokens, None); assert_eq!(config.reasoning, None); } + + #[test] + fn resolves_after_stripping_reasoning_effort_suffix() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + + // "databricks-gpt-5.4-high" should resolve via "databricks-gpt-5.4" + let config = ModelConfig::new_or_fail("databricks-gpt-5.4-high") + .with_canonical_limits("databricks"); + assert_eq!(config.context_limit, Some(1_050_000)); + + // "gpt-5.4-xhigh" should resolve via "gpt-5.4" + let config = ModelConfig::new_or_fail("gpt-5.4-xhigh").with_canonical_limits("openai"); + assert_eq!(config.context_limit, Some(1_050_000)); + + // "gpt-5.4-nano-low" should resolve via "gpt-5.4-nano" + let config = + ModelConfig::new_or_fail("gpt-5.4-nano-low").with_canonical_limits("openai"); + assert_eq!(config.context_limit, Some(400_000)); + } } mod is_openai_reasoning_model { diff --git a/crates/goose/src/providers/acp_tooling.rs b/crates/goose/src/providers/acp_tooling.rs new file mode 100644 index 000000000000..91c9351b6c00 --- /dev/null +++ b/crates/goose/src/providers/acp_tooling.rs @@ -0,0 +1,18 @@ +use crate::config::search_path::SearchPaths; +use crate::providers::inventory::InventoryIdentityInput; +use anyhow::Result; +use std::path::PathBuf; + +pub fn acp_adapter_installed(command: &str) -> bool { + resolve_acp_command(command).is_ok() +} + +pub fn acp_inventory_identity(provider_id: &str, command: &str) -> Result { + let resolved_command = resolve_acp_command(command)?; + Ok(InventoryIdentityInput::new(provider_id, provider_id) + .with_public("command", resolved_command.display().to_string())) +} + +fn resolve_acp_command(command: &str) -> Result { + SearchPaths::builder().with_npm().resolve(command) +} diff --git a/crates/goose/src/providers/amp_acp.rs b/crates/goose/src/providers/amp_acp.rs index 987b370b8c73..5d6339b13f58 100644 --- a/crates/goose/src/providers/amp_acp.rs +++ b/crates/goose/src/providers/amp_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const AMP_ACP_PROVIDER_NAME: &str = "amp-acp"; const AMP_ACP_DOC_URL: &str = "https://ampcode.com"; @@ -37,6 +39,7 @@ impl ProviderDef for AmpAcpProvider { "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: amp-acp\n GOOSE_MODEL: current", "Restart goose for changes to take effect", ]) + .with_model_selection_hint("Use the Amp CLI to configure models") } fn from_env( @@ -49,10 +52,12 @@ impl ProviderDef for AmpAcpProvider { let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); let mode_mapping = HashMap::from([ - (GooseMode::Auto, "auto".to_string()), - (GooseMode::Approve, "approve".to_string()), - (GooseMode::SmartApprove, "smart-approve".to_string()), - (GooseMode::Chat, "chat".to_string()), + // "bypass" skips confirmations, closest to autonomous mode. + (GooseMode::Auto, "bypass".to_string()), + // "default" prompts before risky actions. + (GooseMode::Approve, "default".to_string()), + (GooseMode::SmartApprove, "default".to_string()), + (GooseMode::Chat, "default".to_string()), ]); let provider_config = AcpProviderConfig { @@ -71,4 +76,16 @@ impl ProviderDef for AmpAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + false + } + + fn inventory_identity() -> Result { + acp_inventory_identity(AMP_ACP_PROVIDER_NAME, AMP_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(AMP_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 1529e0afc20b..2a97b2ade7c2 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -14,7 +14,8 @@ use super::errors::ProviderError; use super::formats::anthropic::{ create_request, response_to_streaming_message, thinking_type, ThinkingType, }; -use super::openai_compatible::handle_status_openai_compat; +use super::inventory::{config_secret_value, serialize_string_map, InventoryIdentityInput}; +use super::openai_compatible::handle_status; use super::openai_compatible::map_http_error_to_provider_error; use super::retry::ProviderRetry; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -235,6 +236,33 @@ impl ProviderDef for AnthropicProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + let mut identity = + InventoryIdentityInput::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("ANTHROPIC_HOST") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()), + ); + + if let Some(api_key) = config_secret_value(config, "ANTHROPIC_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Ok(headers) = config + .get_secret::>("ANTHROPIC_CUSTOM_HEADERS") + { + identity = identity.with_secret("headers", serialize_string_map(&headers)?); + } + + Ok(identity) + } } #[async_trait] @@ -294,7 +322,7 @@ impl Provider for AnthropicProvider { request = request.header(key, value)?; } let resp = request.response_post(&payload).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 12b3a3c5104e..6b6a0707b9ef 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -6,9 +6,10 @@ use serde::{Deserialize, Serialize}; use super::canonical::{map_to_canonical_model, CanonicalModelRegistry}; use super::errors::ProviderError; +use super::inventory::{default_inventory_identity, InventoryIdentityInput}; use super::retry::RetryConfig; use crate::config::base::ConfigValue; -use crate::config::{ExtensionConfig, GooseMode}; +use crate::config::{Config, ExtensionConfig, GooseMode}; use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; use crate::model::ModelConfig; @@ -179,6 +180,9 @@ pub struct ProviderMetadata { /// step-by-step instructions for set up providers eg: api key #[serde(default)] pub setup_steps: Vec, + /// Hint shown in the model picker when this provider manages its own model selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_selection_hint: Option, } impl ProviderMetadata { @@ -212,6 +216,7 @@ impl ProviderMetadata { model_doc_link: model_doc_link.to_string(), config_keys, setup_steps: vec![], + model_selection_hint: None, } } @@ -233,6 +238,7 @@ impl ProviderMetadata { model_doc_link: model_doc_link.to_string(), config_keys, setup_steps: vec![], + model_selection_hint: None, } } @@ -246,6 +252,7 @@ impl ProviderMetadata { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -253,6 +260,11 @@ impl ProviderMetadata { self.setup_steps = steps.into_iter().map(|s| s.to_string()).collect(); self } + + pub fn with_model_selection_hint(mut self, hint: &str) -> Self { + self.model_selection_hint = Some(hint.to_string()); + self + } } /// Configuration key metadata for provider setup @@ -492,6 +504,34 @@ pub trait ProviderDef: Send + Sync { ) -> BoxFuture<'static, Result> where Self: Sized; + + fn supports_inventory_refresh() -> bool + where + Self: Sized, + { + false + } + + fn inventory_identity() -> Result + where + Self: Sized, + { + let metadata = Self::metadata(); + Ok(default_inventory_identity( + &metadata.name, + &metadata.name, + &metadata.config_keys, + Config::global(), + )) + } + + fn inventory_configured() -> bool + where + Self: Sized, + { + let metadata = Self::metadata(); + super::inventory::default_inventory_configured(&metadata.config_keys, Config::global()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -588,7 +628,7 @@ pub trait Provider: Send + Sync { false } - /// Fetch models filtered by canonical registry and usability + /// Fetch inventory models filtered by canonical registry and usability. async fn fetch_recommended_models(&self) -> Result, ProviderError> { let all_models = self.fetch_supported_models().await?; @@ -637,15 +677,15 @@ pub trait Provider: Send + Sync { (None, None) => a.0.cmp(&b.0), }); - let recommended_models: Vec = models_with_dates + let inventory_models: Vec = models_with_dates .into_iter() .map(|(name, _)| name) .collect(); - if recommended_models.is_empty() { + if inventory_models.is_empty() { Ok(all_models) } else { - Ok(recommended_models) + Ok(inventory_models) } } diff --git a/crates/goose/src/providers/canonical/data/canonical_models.json b/crates/goose/src/providers/canonical/data/canonical_models.json index 563ca50a1f03..55b2f4628918 100644 --- a/crates/goose/src/providers/canonical/data/canonical_models.json +++ b/crates/goose/src/providers/canonical/data/canonical_models.json @@ -81,6 +81,60 @@ "output": 131072 } }, + { + "id": "302ai/MiniMax-M2.7", + "name": "MiniMax-M2.7", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "302ai/MiniMax-M2.7-highspeed", + "name": "MiniMax-M2.7-highspeed", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 4.8 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, { "id": "302ai/chatgpt-4o", "name": "chatgpt-4o-latest", @@ -111,11 +165,41 @@ "output": 16384 } }, + { + "id": "302ai/claude-3.5-haiku", + "name": "claude-3-5-haiku-latest", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-07", + "release_date": "2024-10-22", + "last_updated": "2024-10-22", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.8, + "output": 4.0 + }, + "limit": { + "context": 200000, + "output": 8192 + } + }, { "id": "302ai/claude-haiku-4.5", "name": "claude-haiku-4-5-20251001", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-03", @@ -124,7 +208,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -140,6 +225,36 @@ "output": 64000 } }, + { + "id": "302ai/claude-opus-4", + "name": "claude-opus-4-20250514", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03", + "release_date": "2025-05-22", + "last_updated": "2025-05-22", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 15.0, + "output": 75.0 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, { "id": "302ai/claude-opus-4.1", "name": "claude-opus-4-1-20250805", @@ -200,9 +315,9 @@ }, { "id": "302ai/claude-opus-4.5", - "name": "claude-opus-4-5-20251101", + "name": "claude-opus-4-5", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-03", @@ -211,7 +326,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -257,19 +373,142 @@ } }, { - "id": "302ai/claude-sonnet-4.5", - "name": "claude-sonnet-4-5-20250929", + "id": "302ai/claude-opus-4.6", + "name": "claude-opus-4-6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-02-06", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "302ai/claude-opus-4.6-thinking", + "name": "claude-opus-4-6-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-02-06", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "302ai/claude-opus-4.7", + "name": "claude-opus-4-7", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 128000 + } + }, + { + "id": "302ai/claude-sonnet-4", + "name": "claude-sonnet-4-20250514", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, "knowledge": "2025-03", - "release_date": "2025-09-29", - "last_updated": "2025-09-29", + "release_date": "2025-05-22", + "last_updated": "2025-05-22", "modalities": { "input": [ "text", - "image" + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "302ai/claude-sonnet-4.5", + "name": "claude-sonnet-4-5", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07", + "release_date": "2025-09-30", + "last_updated": "2025-09-30", + "modalities": { + "input": [ + "text", + "image", + "pdf" ], "output": [ "text" @@ -298,7 +537,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -314,6 +554,66 @@ "output": 64000 } }, + { + "id": "302ai/claude-sonnet-4.6", + "name": "claude-sonnet-4-6", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-02-18", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "302ai/claude-sonnet-4.6-thinking", + "name": "claude-sonnet-4-6-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-02-18", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "302ai/deepseek-chat", "name": "Deepseek-Chat", @@ -834,6 +1134,37 @@ "output": 64000 } }, + { + "id": "302ai/gemini-3.1-flash-image-preview", + "name": "gemini-3.1-flash-image-preview", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-27", + "last_updated": "2026-02-27", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text", + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 60.0 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, { "id": "302ai/glm-4.5", "name": "GLM-4.5", @@ -862,6 +1193,90 @@ "output": 98304 } }, + { + "id": "302ai/glm-4.5-air", + "name": "glm-4.5-air", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-07-29", + "last_updated": "2025-07-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.1143, + "output": 0.286 + }, + "limit": { + "context": 128000, + "output": 98304 + } + }, + { + "id": "302ai/glm-4.5-airx", + "name": "glm-4.5-airx", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-07-29", + "last_updated": "2025-07-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.572, + "output": 1.714 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "302ai/glm-4.5-x", + "name": "glm-4.5-x", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-07-29", + "last_updated": "2025-07-29", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.143, + "output": 2.29 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, { "id": "302ai/glm-4.5v", "name": "GLM-4.5V", @@ -870,8 +1285,8 @@ "tool_call": true, "temperature": true, "knowledge": "2024-10", - "release_date": "2025-07-29", - "last_updated": "2025-07-29", + "release_date": "2025-08-12", + "last_updated": "2025-08-12", "modalities": { "input": [ "text", @@ -895,7 +1310,7 @@ "id": "302ai/glm-4.6", "name": "glm-4.6", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-03", @@ -952,7 +1367,7 @@ "id": "302ai/glm-4.7", "name": "glm-4.7", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, "knowledge": "2025-06", @@ -976,6 +1391,173 @@ "output": 131072 } }, + { + "id": "302ai/glm-4.7-flashx", + "name": "glm-4.7-flashx", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-01-20", + "last_updated": "2026-01-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0715, + "output": 0.429 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "302ai/glm-5", + "name": "glm-5", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 2.6 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "302ai/glm-5-turbo", + "name": "glm-5-turbo", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.72, + "output": 3.2 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "302ai/glm-5.1", + "name": "glm-5.1", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-10", + "last_updated": "2026-04-10", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.86, + "output": 3.5 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "302ai/glm-5v-turbo", + "name": "glm-5v-turbo", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.72, + "output": 3.2 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "302ai/glm-for-coding", + "name": "glm-for-coding", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-09-30", + "last_updated": "2025-09-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.086, + "output": 0.343 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, { "id": "302ai/gpt-4.1", "name": "gpt-4.1", @@ -1328,6 +1910,64 @@ "output": 16384 } }, + { + "id": "302ai/gpt-5.4-mini", + "name": "gpt-5.4-mini-2026-03-17", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 4.5 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "302ai/gpt-5.4-nano", + "name": "gpt-5.4-nano-2026-03-17", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 1.25 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, { "id": "302ai/grok-4-fast", "name": "grok-4-fast-reasoning", @@ -1473,6 +2113,90 @@ "output": 30000 } }, + { + "id": "302ai/grok-4.20-beta", + "name": "grok-4.20-beta-0309-reasoning", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, + { + "id": "302ai/grok-4.20-beta-0309-non", + "name": "grok-4.20-beta-0309-non-reasoning", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, + { + "id": "302ai/grok-4.20-multi-agent-beta", + "name": "grok-4.20-multi-agent-beta-0309", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 6.0 + }, + "limit": { + "context": 2000000, + "output": 30000 + } + }, { "id": "302ai/kimi-k2-0905-preview", "name": "kimi-k2-0905-preview", @@ -2143,7 +2867,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -2235,7 +2959,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -3908,7 +4632,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -3941,7 +4665,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -4007,7 +4731,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -4040,7 +4764,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -5814,6 +6538,37 @@ "output": 32768 } }, + { + "id": "alibaba-cn/kimi-k2.6", + "name": "Moonshot Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.929, + "output": 3.858 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "alibaba-cn/kimi/kimi-k2.5", "name": "kimi/kimi-k2.5", @@ -9574,7 +10329,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-07", + "knowledge": "2024-07-31", "release_date": "2024-10-22", "last_updated": "2024-10-22", "modalities": { @@ -9607,7 +10362,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2024-04-30", "release_date": "2024-06-20", "last_updated": "2024-06-20", "modalities": { @@ -9739,7 +10494,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -9838,9 +10593,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -9871,7 +10626,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -9904,7 +10659,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -9970,9 +10725,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -9995,6 +10750,72 @@ "output": 64000 } }, + { + "id": "amazon-bedrock/au.anthropic.claude-opus-4.6-v1", + "name": "AU Anthropic Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 16.5, + "output": 82.5, + "cache_read": 1.65, + "cache_write": 20.625 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "amazon-bedrock/au.anthropic.claude-sonnet-4.6", + "name": "AU Anthropic Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-02-17", + "last_updated": "2026-02-17", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.3, + "output": 16.5, + "cache_read": 0.33, + "cache_write": 4.125 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "amazon-bedrock/deepseek.r1-v1:0", "name": "DeepSeek-R1", @@ -10156,9 +10977,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -10189,7 +11010,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -10222,7 +11043,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -10288,9 +11109,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -10387,9 +11208,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -10420,7 +11241,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -10453,7 +11274,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -10519,9 +11340,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -11793,7 +12614,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -11892,9 +12713,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -11925,7 +12746,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -11958,7 +12779,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-04", + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { @@ -12024,9 +12845,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-03-18", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -12562,7 +13383,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-03-13", "modalities": { @@ -12595,7 +13416,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -12727,7 +13548,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-03-13", "modalities": { @@ -12859,7 +13680,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -13412,36 +14233,36 @@ { "id": "azure-cognitive-services/gpt-4-turbo", "name": "GPT-4 Turbo", - "family": "gpt", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2023-11", - "release_date": "2023-11-06", - "last_updated": "2024-04-09", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 10.0, - "output": 30.0 - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, - { - "id": "azure-cognitive-services/gpt-4-turbo-vision", - "name": "GPT-4 Turbo Vision", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2023-11-06", + "last_updated": "2024-04-09", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 10.0, + "output": 30.0 + }, + "limit": { + "context": 128000, + "output": 4096 + } + }, + { + "id": "azure-cognitive-services/gpt-4-turbo-vision", + "name": "GPT-4 Turbo Vision", "family": "gpt", "attachment": true, "reasoning": false, @@ -13477,7 +14298,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -13508,7 +14329,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -13539,7 +14360,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -13572,7 +14393,7 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", @@ -15653,7 +16474,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -15711,6 +16532,39 @@ "output": 64000 } }, + { + "id": "azure/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "azure/codestral", "name": "Codestral 25.01", @@ -16211,7 +17065,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2023-11", + "knowledge": "2023-12", "release_date": "2023-11-06", "last_updated": "2024-04-09", "modalities": { @@ -16271,7 +17125,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -16302,7 +17156,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -16333,7 +17187,7 @@ "reasoning": false, "tool_call": true, "temperature": true, - "knowledge": "2024-05", + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -16366,7 +17220,7 @@ "temperature": true, "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", @@ -18780,6 +19634,38 @@ "output": 8192 } }, + { + "id": "baseten/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "family": "kimi-k2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "baseten/nvidia/Nemotron-120B-A12B", "name": "Nemotron 3 Super", @@ -18926,106 +19812,22 @@ } }, { - "id": "berget/BAAI/bge-reranker-v2-m3", - "name": "bge-reranker-v2-m3", - "family": "bge", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": false, - "knowledge": "2025-04", - "release_date": "2025-04-23", - "last_updated": "2025-04-23", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "limit": { - "context": 512, - "output": 512 - } - }, - { - "id": "berget/KBLab/kb-whisper-large", - "name": "KB-Whisper-Large", - "family": "whisper", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": false, - "knowledge": "2025-04", - "release_date": "2025-04-27", - "last_updated": "2025-04-27", - "modalities": { - "input": [ - "audio" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 3.0, - "output": 3.0 - }, - "limit": { - "context": 480000, - "output": 4800 - } - }, - { - "id": "berget/intfloat/multilingual-e5-large", - "name": "Multilingual-E5-large", - "family": "text-embedding", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": false, - "knowledge": "2025-09", - "release_date": "2025-09-11", - "last_updated": "2025-09-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.02, - "output": 0.0 - }, - "limit": { - "context": 512, - "output": 1024 - } - }, - { - "id": "berget/intfloat/multilingual-e5-large-instruct", - "name": "Multilingual-E5-large-instruct", - "family": "text-embedding", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": false, - "knowledge": "2025-04", - "release_date": "2025-04-27", - "last_updated": "2025-04-27", + "id": "berget/google/gemma-4-31B-it", + "name": "Gemma 4 31B Instruct", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", "modalities": { "input": [ - "text" + "audio", + "image", + "text", + "video" ], "output": [ "text" @@ -19033,12 +19835,12 @@ }, "open_weights": true, "cost": { - "input": 0.02, - "output": 0.0 + "input": 0.275, + "output": 0.55 }, "limit": { - "context": 512, - "output": 1024 + "context": 128000, + "output": 8192 } }, { @@ -19062,8 +19864,8 @@ }, "open_weights": true, "cost": { - "input": 0.9, - "output": 0.9 + "input": 0.99, + "output": 0.99 }, "limit": { "context": 128000, @@ -19091,8 +19893,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.33, + "output": 0.33 }, "limit": { "context": 32000, @@ -19120,8 +19922,8 @@ }, "open_weights": true, "cost": { - "input": 0.3, - "output": 0.9 + "input": 0.44, + "output": 0.99 }, "limit": { "context": 128000, @@ -19149,8 +19951,8 @@ }, "open_weights": true, "cost": { - "input": 0.7, - "output": 2.3 + "input": 0.77, + "output": 2.75 }, "limit": { "context": 128000, @@ -20467,6 +21269,37 @@ "output": 65535 } }, + { + "id": "chutes/moonshotai/Kimi-K2.6-TEE", + "name": "Kimi K2.6 TEE", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12", + "release_date": "2026-04-20", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.44, + "output": 2.0 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "chutes/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", "name": "NVIDIA Nemotron 3 Nano 30B A3B BF16", @@ -21996,7 +22829,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08-31", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -22128,7 +22961,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-07-31", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -23518,46 +24351,20 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/myshell-ai/melotts", - "name": "MyShell MeloTTS", - "family": "melotts", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-11-14", - "last_updated": "2025-11-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/nvidia/nemotron-3-120b-a12b", - "name": "Nemotron 3 Super 120B", - "family": "nemotron", - "attachment": false, + "id": "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-03-11", - "last_updated": "2026-03-11", + "knowledge": "2025-01", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -23565,8 +24372,9 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 1.5 + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 }, "limit": { "context": 256000, @@ -23574,91 +24382,9 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-120b", - "name": "GPT OSS 120B", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.35, - "output": 0.75 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-20b", - "name": "GPT OSS 20B", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.2, - "output": 0.3 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/pfnet/plamo-embedding-1b", - "name": "PLaMo Embedding 1B", - "family": "plamo", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-09-25", - "last_updated": "2025-09-25", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.019, - "output": 0.0 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/pipecat-ai/smart-turn-v2", - "name": "Pipecat Smart Turn v2", - "family": "smart-turn", + "id": "cloudflare-ai-gateway/workers-ai/@cf/myshell-ai/melotts", + "name": "MyShell MeloTTS", + "family": "melotts", "attachment": false, "reasoning": false, "tool_call": false, @@ -23684,237 +24410,406 @@ } }, { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen2.5-coder-32b-instruct", - "name": "Qwen 2.5 Coder 32B Instruct", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-04-11", - "last_updated": "2025-04-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.66, - "output": 1.0 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen3-30b-a3b-fp8", - "name": "Qwen3 30B A3B FP8", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-11-14", - "last_updated": "2025-11-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.051, - "output": 0.34 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen3-embedding-0.6b", - "name": "Qwen3 Embedding 0.6B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-11-14", - "last_updated": "2025-11-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.012, - "output": 0.0 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwq-32b", - "name": "QwQ 32B", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-04-11", - "last_updated": "2025-04-11", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.66, - "output": 1.0 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-4.7-flash", - "name": "GLM-4.7-Flash", - "family": "glm-flash", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-01-19", - "last_updated": "2026-01-19", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.06, - "output": 0.4 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, - { - "id": "cloudflare-workers-ai/@cf/google/gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", - "family": "gemma", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-12-15", - "last_updated": "2025-12-15", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.1, - "output": 0.3 - }, - "limit": { - "context": 256000, - "output": 16384 - } - }, - { - "id": "cloudflare-workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct", - "name": "Llama 4 Scout 17B 16E Instruct", - "family": "llama", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2025-04-16", - "last_updated": "2025-04-16", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.27, - "output": 0.85 - }, - "limit": { - "context": 128000, - "output": 16384 - } - }, - { - "id": "cloudflare-workers-ai/@cf/moonshotai/kimi-k2.5", - "name": "Kimi K2.5", - "family": "kimi", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-01", - "release_date": "2026-01-27", - "last_updated": "2026-01-27", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.1 - }, - "limit": { - "context": 256000, - "output": 256000 - } - }, - { - "id": "cloudflare-workers-ai/@cf/nvidia/nemotron-3-120b-a12b", + "id": "cloudflare-ai-gateway/workers-ai/@cf/nvidia/nemotron-3-120b-a12b", + "name": "Nemotron 3 Super 120B", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-11", + "last_updated": "2026-03-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.5, + "output": 1.5 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.35, + "output": 0.75 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.2, + "output": 0.3 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/pfnet/plamo-embedding-1b", + "name": "PLaMo Embedding 1B", + "family": "plamo", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-09-25", + "last_updated": "2025-09-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.019, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/pipecat-ai/smart-turn-v2", + "name": "Pipecat Smart Turn v2", + "family": "smart-turn", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-14", + "last_updated": "2025-11-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen2.5-coder-32b-instruct", + "name": "Qwen 2.5 Coder 32B Instruct", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-04-11", + "last_updated": "2025-04-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.66, + "output": 1.0 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen3-30b-a3b-fp8", + "name": "Qwen3 30B A3B FP8", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-14", + "last_updated": "2025-11-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.051, + "output": 0.34 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwen3-embedding-0.6b", + "name": "Qwen3 Embedding 0.6B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-11-14", + "last_updated": "2025-11-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.012, + "output": 0.0 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/qwen/qwq-32b", + "name": "QwQ 32B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2025-04-11", + "last_updated": "2025-04-11", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.66, + "output": 1.0 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-4.7-flash", + "name": "GLM-4.7-Flash", + "family": "glm-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.06, + "output": 0.4 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, + { + "id": "cloudflare-workers-ai/@cf/google/gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B IT", + "family": "gemma", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-12-15", + "last_updated": "2025-12-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.3 + }, + "limit": { + "context": 256000, + "output": 16384 + } + }, + { + "id": "cloudflare-workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct", + "name": "Llama 4 Scout 17B 16E Instruct", + "family": "llama", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-04-16", + "last_updated": "2025-04-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.27, + "output": 0.85 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "cloudflare-workers-ai/@cf/moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-01-27", + "last_updated": "2026-01-27", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, + { + "id": "cloudflare-workers-ai/@cf/nvidia/nemotron-3-120b-a12b", "name": "Nemotron 3 Super 120B", "family": "nemotron", "attachment": false, @@ -24394,7 +25289,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-03-13", "modalities": { @@ -24487,7 +25382,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-03-13", "modalities": { @@ -24510,6 +25405,39 @@ "output": 1000000 } }, + { + "id": "cortecs/claude-opus4-7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.6, + "output": 27.99, + "cache_read": 0.56, + "cache_write": 6.99 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "cortecs/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -25005,6 +25933,36 @@ "output": 256000 } }, + { + "id": "cortecs/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.81, + "output": 3.54, + "cache_read": 0.2 + }, + "limit": { + "context": 256000, + "output": 256000 + } + }, { "id": "cortecs/llama-3.1-405b-instruct", "name": "Llama 3.1 405B Instruct", @@ -25438,6 +26396,98 @@ "output": 66536 } }, + { + "id": "deepinfra/Qwen/Qwen3.5-35B-A3B", + "name": "Qwen 3.5 35B A3B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-01", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 0.95 + }, + "limit": { + "context": 262144, + "output": 81920 + } + }, + { + "id": "deepinfra/Qwen/Qwen3.5-397B-A17B", + "name": "Qwen 3.5 397B A17B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-02-01", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.54, + "output": 3.4 + }, + "limit": { + "context": 262144, + "output": 81920 + } + }, + { + "id": "deepinfra/Qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B A3B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-01", + "last_updated": "2026-04-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 1.0 + }, + "limit": { + "context": 262144, + "output": 81920 + } + }, { "id": "deepinfra/anthropic/claude-3.7-sonnet", "name": "Claude Sonnet 3.7 (Latest)", @@ -25838,6 +26888,38 @@ "output": 32768 } }, + { + "id": "deepinfra/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.75, + "output": 3.5, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "deepinfra/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -25856,144 +26938,1588 @@ "text" ] }, - "open_weights": true, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.24 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "deepinfra/openai/gpt-oss-20b", + "name": "GPT OSS 20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.03, + "output": 0.14 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "deepinfra/zai-org/GLM-4.5", + "name": "GLM-4.5", + "family": "glm", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.2 + }, + "limit": { + "context": 131072, + "output": 98304 + } + }, + { + "id": "deepinfra/zai-org/GLM-4.6", + "name": "GLM-4.6", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09-30", + "last_updated": "2025-09-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.43, + "output": 1.74, + "cache_read": 0.08 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "deepinfra/zai-org/GLM-4.6V", + "name": "GLM-4.6V", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-09-30", + "last_updated": "2025-09-30", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 0.9 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, + { + "id": "deepinfra/zai-org/GLM-4.7", + "name": "GLM-4.7", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2025-12-22", + "last_updated": "2025-12-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.43, + "output": 1.75, + "cache_read": 0.08 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "deepinfra/zai-org/GLM-4.7-Flash", + "name": "GLM-4.7-Flash", + "family": "glm-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.06, + "output": 0.4 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "deepinfra/zai-org/GLM-5", + "name": "GLM-5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-12", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.8, + "output": 2.56, + "cache_read": 0.16 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "deepinfra/zai-org/GLM-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 202752, + "output": 16384 + } + }, + { + "id": "deepseek/deepseek-chat", + "name": "DeepSeek Chat", + "family": "deepseek", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2025-12-01", + "last_updated": "2026-02-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.28, + "output": 0.42, + "cache_read": 0.028 + }, + "limit": { + "context": 131072, + "output": 8192 + } + }, + { + "id": "deepseek/deepseek-reasoner", + "name": "DeepSeek Reasoner", + "family": "deepseek-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-09", + "release_date": "2025-12-01", + "last_updated": "2026-02-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.28, + "output": 0.42, + "cache_read": 0.028 + }, + "limit": { + "context": 128000, + "output": 64000 + } + }, + { + "id": "deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48, + "cache_read": 0.145 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "digitalocean/alibaba-qwen3-32b", + "name": "Qwen3-32B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-04-30", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 0.55 + }, + "limit": { + "context": 131000, + "output": 40960 + } + }, + { + "id": "digitalocean/all-mini-lm-l6-v2", + "name": "All-MiniLM-L6-v2", + "family": "text-embedding", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2021-08-30", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.009, + "output": 0.0 + }, + "limit": { + "context": 256, + "output": 384 + } + }, + { + "id": "digitalocean/anthropic-claude-4.1-opus", + "name": "Claude Opus 4.1", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 15.0, + "output": 75.0, + "cache_read": 1.5, + "cache_write": 18.75 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "digitalocean/anthropic-claude-4.5-sonnet", + "name": "Claude Sonnet 4.5", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "digitalocean/anthropic-claude-4.6-sonnet", + "name": "Claude Sonnet 4.6", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "digitalocean/anthropic-claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 5.0, + "cache_read": 1.0, + "cache_write": 1.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "digitalocean/anthropic-claude-opus-4", + "name": "Claude Opus 4", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-05-22", + "last_updated": "2025-05-22", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 15.0, + "output": 75.0, + "cache_read": 1.5, + "cache_write": 18.75 + }, + "limit": { + "context": 200000, + "output": 32000 + } + }, + { + "id": "digitalocean/anthropic-claude-opus-4.5", + "name": "Claude Opus 4.5", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 200000, + "output": 64000 + } + }, + { + "id": "digitalocean/anthropic-claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-05", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "digitalocean/anthropic-claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "digitalocean/anthropic-claude-sonnet-4", + "name": "Claude Sonnet 4", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-03-31", + "release_date": "2025-05-22", + "last_updated": "2025-05-22", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 3.0, + "output": 15.0, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "digitalocean/arcee-trinity-large-thinking", + "name": "Trinity Large Thinking", + "family": "trinity", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.25, + "output": 0.9, + "cache_read": 0.06 + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, + { + "id": "digitalocean/deepseek-r1-distill-llama-70b", + "name": "DeepSeek R1 Distill Llama 70B", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-01-30", + "last_updated": "2025-01-30", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.99, + "output": 0.99 + }, + "limit": { + "context": 131072, + "output": 32768 + } + }, + { + "id": "digitalocean/fal-ai/elevenlabs/tts/multilingual-v2", + "name": "ElevenLabs Multilingual TTS v2", + "family": "elevenlabs", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2023-08-22", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "digitalocean/fal-ai/fast-sdxl", + "name": "Fast SDXL", + "family": "stable-diffusion", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2023-07-26", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "digitalocean/fal-ai/flux/schnell", + "name": "FLUX.1 [schnell]", + "family": "flux", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2024-08-01", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "digitalocean/fal-ai/stable-audio-25/text-to-audio", + "name": "Stable Audio 2.5 (Text-to-Audio)", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-10-08", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "open_weights": false, + "cost": {}, + "limit": { + "context": 0, + "output": 0 + } + }, + { + "id": "digitalocean/glm-5", + "name": "GLM 5", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "release_date": "2026-02-11", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.2 + }, + "limit": { + "context": 202752, + "output": 128000 + } + }, + { + "id": "digitalocean/gte-large-en-v1.5", + "name": "GTE Large (v1.5)", + "family": "text-embedding", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2024-03-27", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.09, + "output": 0.0 + }, + "limit": { + "context": 8192, + "output": 1024 + } + }, + { + "id": "digitalocean/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.5, + "output": 2.7 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, + { + "id": "digitalocean/llama3.3-70b-instruct", + "name": "Llama 3.3 Instruct 70B", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-12", + "release_date": "2024-12-06", + "last_updated": "2024-12-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.65, + "output": 0.65 + }, + "limit": { + "context": 128000, + "output": 128000 + } + }, + { + "id": "digitalocean/minimax-m2.5", + "name": "MiniMax M2.5", + "family": "minimax-m2.5", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-08", + "release_date": "2026-02-12", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 204800, + "output": 128000 + } + }, + { + "id": "digitalocean/multi-qa-mpnet-base-dot-v1", + "name": "Multi-QA-mpnet-base-dot-v1", + "family": "text-embedding", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2021-08-30", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.009, + "output": 0.0 + }, + "limit": { + "context": 512, + "output": 768 + } + }, + { + "id": "digitalocean/nvidia-nemotron-3-super-120b", + "name": "Nemotron-3-Super-120B", + "family": "nemotron", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2026-02", + "release_date": "2026-03-11", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 0.65 + }, + "limit": { + "context": 256000, + "output": 32768 + } + }, + { + "id": "digitalocean/openai-gpt-4.1", + "name": "GPT-4.1", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1047576, + "output": 32768 + } + }, + { + "id": "digitalocean/openai-gpt-4o", + "name": "GPT-4o", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-09", + "release_date": "2024-05-13", + "last_updated": "2024-08-06", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 10.0, + "cache_read": 1.25 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "digitalocean/openai-gpt-4o-mini", + "name": "GPT-4o mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2023-09", + "release_date": "2024-07-18", + "last_updated": "2024-07-18", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "digitalocean/openai-gpt-5", + "name": "GPT-5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5-mini", + "name": "GPT-5 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.25, + "output": 2.0, + "cache_read": 0.025 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5-nano", + "name": "GPT-5 nano", + "family": "gpt-nano", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.25, + "output": 10.0, + "cache_read": 0.125 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.2", + "name": "GPT-5.2", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.2-pro", + "name": "GPT-5.2 pro", + "family": "gpt-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 21.0, + "output": 168.0 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "family": "gpt-codex", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.75, + "output": 14.0, + "cache_read": 0.175 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.4", + "name": "GPT-5.4", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "digitalocean/openai-gpt-5.4-mini", + "name": "GPT-5.4 mini", + "family": "gpt-mini", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, "cost": { - "input": 0.05, - "output": 0.24 + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 }, "limit": { - "context": 131072, - "output": 16384 + "context": 400000, + "output": 128000 } }, { - "id": "deepinfra/openai/gpt-oss-20b", - "name": "GPT OSS 20B", - "family": "gpt-oss", - "attachment": false, + "id": "digitalocean/openai-gpt-5.4-nano", + "name": "GPT-5.4 nano", + "family": "gpt-nano", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.03, - "output": 0.14 + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 }, "limit": { - "context": 131072, - "output": 16384 + "context": 400000, + "output": 128000 } }, { - "id": "deepinfra/zai-org/GLM-4.5", - "name": "GLM-4.5", - "family": "glm", - "attachment": false, - "reasoning": false, + "id": "digitalocean/openai-gpt-5.4-pro", + "name": "GPT-5.4 pro", + "family": "gpt-pro", + "attachment": true, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-07-28", - "last_updated": "2025-07-28", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.6, - "output": 2.2 + "input": 30.0, + "output": 180.0 }, "limit": { - "context": 131072, - "output": 98304 + "context": 400000, + "output": 128000 } }, { - "id": "deepinfra/zai-org/GLM-4.6", - "name": "GLM-4.6", - "family": "glm", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-09-30", - "last_updated": "2025-09-30", + "id": "digitalocean/openai-gpt-image-1", + "name": "GPT Image 1", + "family": "gpt-image", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-04-24", + "last_updated": "2025-04-24", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ - "text" + "image" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.43, - "output": 1.74, - "cache_read": 0.08 + "input": 5.0, + "output": 40.0, + "cache_read": 1.25 }, "limit": { - "context": 204800, - "output": 131072 + "context": 0, + "output": 0 } }, { - "id": "deepinfra/zai-org/GLM-4.6V", - "name": "GLM-4.6V", - "family": "glm", + "id": "digitalocean/openai-gpt-image-1.5", + "name": "GPT Image 1.5", + "family": "gpt-image", "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-09-30", - "last_updated": "2025-09-30", + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-11-25", + "last_updated": "2025-11-25", "modalities": { "input": [ "text", "image" ], "output": [ - "text" + "image" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.3, - "output": 0.9 + "input": 5.0, + "output": 10.0, + "cache_read": 1.0 }, "limit": { - "context": 204800, - "output": 131072 + "context": 0, + "output": 0 } }, { - "id": "deepinfra/zai-org/GLM-4.7", - "name": "GLM-4.7", - "family": "glm", + "id": "digitalocean/openai-gpt-oss-120b", + "name": "gpt-oss-120b", + "family": "gpt-oss", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2025-12-22", - "last_updated": "2025-12-22", + "knowledge": "2024-06", + "release_date": "2025-08-05", + "last_updated": "2026-04-16", "modalities": { "input": [ "text" @@ -26004,26 +28530,25 @@ }, "open_weights": true, "cost": { - "input": 0.43, - "output": 1.75, - "cache_read": 0.08 + "input": 0.1, + "output": 0.7 }, "limit": { - "context": 202752, - "output": 16384 + "context": 131072, + "output": 131072 } }, { - "id": "deepinfra/zai-org/GLM-4.7-Flash", - "name": "GLM-4.7-Flash", - "family": "glm-flash", + "id": "digitalocean/openai-gpt-oss-20b", + "name": "gpt-oss-20b", + "family": "gpt-oss", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-01-19", - "last_updated": "2026-01-19", + "knowledge": "2024-06", + "release_date": "2025-08-05", + "last_updated": "2026-04-16", "modalities": { "input": [ "text" @@ -26034,85 +28559,89 @@ }, "open_weights": true, "cost": { - "input": 0.06, - "output": 0.4 + "input": 0.05, + "output": 0.45 }, "limit": { - "context": 202752, - "output": 16384 + "context": 131072, + "output": 131072 } }, { - "id": "deepinfra/zai-org/GLM-5", - "name": "GLM-5", - "family": "glm", - "attachment": false, + "id": "digitalocean/openai-o1", + "name": "o1", + "family": "o", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-12", - "release_date": "2026-02-12", - "last_updated": "2026-02-12", + "temperature": false, + "knowledge": "2023-09", + "release_date": "2024-12-05", + "last_updated": "2024-12-05", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.8, - "output": 2.56, - "cache_read": 0.16 + "input": 15.0, + "output": 60.0, + "cache_read": 7.5 }, "limit": { - "context": 202752, - "output": 16384 + "context": 200000, + "output": 100000 } }, { - "id": "deepinfra/zai-org/GLM-5.1", - "name": "GLM-5.1", - "family": "glm", - "attachment": false, + "id": "digitalocean/openai-o3", + "name": "o3", + "family": "o", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-04", - "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "temperature": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 2.0, + "output": 8.0, + "cache_read": 0.5 }, "limit": { - "context": 202752, - "output": 16384 + "context": 200000, + "output": 100000 } }, { - "id": "deepseek/deepseek-chat", - "name": "DeepSeek Chat", - "family": "deepseek", - "attachment": true, - "reasoning": false, + "id": "digitalocean/openai-o3-mini", + "name": "o3-mini", + "family": "o-mini", + "attachment": false, + "reasoning": true, "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", + "temperature": false, + "knowledge": "2024-05", + "release_date": "2024-12-20", + "last_updated": "2025-01-29", "modalities": { "input": [ "text" @@ -26121,28 +28650,27 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.028 + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 }, "limit": { - "context": 131072, - "output": 8192 + "context": 200000, + "output": 100000 } }, { - "id": "deepseek/deepseek-reasoner", - "name": "DeepSeek Reasoner", - "family": "deepseek-thinking", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", + "id": "digitalocean/qwen3-embedding-0.6b", + "name": "Qwen3 Embedding 0.6B", + "family": "text-embedding", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2025-06-03", + "last_updated": "2026-04-16", "modalities": { "input": [ "text" @@ -26153,13 +28681,12 @@ }, "open_weights": true, "cost": { - "input": 0.28, - "output": 0.42, - "cache_read": 0.028 + "input": 0.04, + "output": 0.0 }, "limit": { - "context": 128000, - "output": 64000 + "context": 8000, + "output": 1024 } }, { @@ -27497,6 +30024,36 @@ "output": 256000 } }, + { + "id": "fireworks-ai/accounts/fireworks/models/kimi-k2p6", + "name": "Kimi K2.6", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, { "id": "fireworks-ai/accounts/fireworks/models/minimax-m2p1", "name": "MiniMax-M2.1", @@ -27719,7 +30276,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -27752,7 +30309,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2026-02-17", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -28280,6 +30837,35 @@ "output": 128000 } }, + { + "id": "firmware/kimi-k2-6", + "name": "Kimi-K2.6", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "1970-01-01", + "last_updated": "1970-01-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, { "id": "firmware/kimi-k2.5", "name": "Kimi-K2.5", @@ -28372,7 +30958,7 @@ }, { "id": "firmware/zai-glm-5.1", - "name": "GLM-5", + "name": "Z.AI GLM-5.1", "family": "glm", "attachment": true, "reasoning": false, @@ -28639,7 +31225,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -28661,6 +31247,36 @@ "output": 64000 } }, + { + "id": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 144000, + "output": 64000 + } + }, { "id": "github-copilot/claude-opus-41", "name": "Claude Opus 4.1", @@ -28759,6 +31375,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -31230,7 +33847,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-03-31", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -31255,6 +33872,39 @@ "output": 64000 } }, + { + "id": "gitlab/duo-chat-opus-4.7", + "name": "Agentic Chat (Claude Opus 4.7)", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, { "id": "gitlab/duo-chat-sonnet-4.5", "name": "Agentic Chat (Claude Sonnet 4.5)", @@ -31528,8 +34178,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-03-31", - "release_date": "2025-11-24", - "last_updated": "2025-11-24", + "release_date": "2025-11-01", + "last_updated": "2025-11-01", "modalities": { "input": [ "text", @@ -31560,9 +34210,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-02-05", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -31593,7 +34243,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -31692,7 +34342,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -32110,7 +34760,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.31 + "cache_read": 0.125 }, "limit": { "context": 1048576, @@ -32843,7 +35493,7 @@ "cost": { "input": 0.3, "output": 2.5, - "cache_read": 0.075 + "cache_read": 0.03 }, "limit": { "context": 1048576, @@ -33174,7 +35824,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.31 + "cache_read": 0.125 }, "limit": { "context": 1048576, @@ -33792,7 +36442,7 @@ } }, { - "id": "google/gemma-4-26b-it", + "id": "google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B", "family": "gemma", "attachment": false, @@ -35011,37 +37661,6 @@ "output": 64000 } }, - { - "id": "helicone/codex-mini", - "name": "OpenAI Codex Mini Latest", - "family": "gpt-codex-mini", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": false, - "knowledge": "2025-01", - "release_date": "2025-01-01", - "last_updated": "2025-01-01", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.5, - "output": 6.0, - "cache_read": 0.375 - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, { "id": "helicone/deepseek-r1-distill-llama-70b", "name": "DeepSeek R1 Distill Llama 70B", @@ -37833,6 +40452,38 @@ "output": 262144 } }, + { + "id": "huggingface/moonshotai/Kimi-K2.6", + "name": "Kimi-K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "huggingface/zai-org/GLM-4.7", "name": "GLM-4.7", @@ -39362,6 +42013,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-02", "last_updated": "2026-02", "modalities": { @@ -41664,6 +44316,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -41694,6 +44347,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-04-07", "last_updated": "2026-04-11", "modalities": { @@ -41786,6 +44440,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-03-15", "modalities": { @@ -45180,6 +47835,35 @@ "output": 65535 } }, + { + "id": "kilo/moonshotai/kimi-k2.6", + "name": "MoonshotAI: Kimi K2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "kilo/morph/morph-v3-fast", "name": "Morph: Morph V3 Fast", @@ -50027,6 +52711,39 @@ "output": 32768 } }, + { + "id": "kimi-for-coding/k2p6", + "name": "Kimi K2.6", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04", + "last_updated": "2026-04", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "kimi-for-coding/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -50118,36 +52835,6 @@ "output": 16384 } }, - { - "id": "llmgateway/claude-3-haiku", - "name": "Claude 3 Haiku", - "family": "claude", - "attachment": true, - "reasoning": false, - "tool_call": true, - "temperature": true, - "release_date": "2024-03-04", - "last_updated": "2024-03-04", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.25, - "output": 1.25, - "cache_read": 0.03 - }, - "limit": { - "context": 200000, - "output": 4096 - } - }, { "id": "llmgateway/claude-3-opus", "name": "Claude 3 Opus", @@ -50209,17 +52896,20 @@ }, { "id": "llmgateway/claude-3.5-sonnet", - "name": "Claude 3.5 Sonnet", - "family": "claude", - "attachment": false, + "name": "Claude Sonnet 3.5 v2", + "family": "claude-sonnet", + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2024-06-20", - "last_updated": "2024-06-20", + "knowledge": "2024-04-30", + "release_date": "2024-10-22", + "last_updated": "2024-10-22", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50229,26 +52919,30 @@ "cost": { "input": 3.0, "output": 15.0, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { "context": 200000, - "output": 16384 + "output": 8192 } }, { "id": "llmgateway/claude-3.7-sonnet", - "name": "Claude 3.7 Sonnet (2025-02-19)", - "family": "claude", - "attachment": false, + "name": "Claude Sonnet 3.7", + "family": "claude-sonnet", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-10-31", "release_date": "2025-02-19", "last_updated": "2025-02-19", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50258,26 +52952,30 @@ "cost": { "input": 3.0, "output": 15.0, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { "context": 200000, - "output": 8192 + "output": 64000 } }, { "id": "llmgateway/claude-haiku-4.5", - "name": "Claude Haiku 4.5 (2025-10-01)", - "family": "claude", - "attachment": false, - "reasoning": false, + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-02-28", "release_date": "2025-10-15", "last_updated": "2025-10-15", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50287,7 +52985,8 @@ "cost": { "input": 1.0, "output": 5.0, - "cache_read": 0.1 + "cache_read": 0.1, + "cache_write": 1.25 }, "limit": { "context": 200000, @@ -50296,17 +52995,20 @@ }, { "id": "llmgateway/claude-opus-4", - "name": "Claude Opus 4 (2025-05-14)", - "family": "claude", - "attachment": false, + "name": "Claude Opus 4", + "family": "claude-opus", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-03-31", "release_date": "2025-05-22", "last_updated": "2025-05-22", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50316,27 +53018,30 @@ "cost": { "input": 15.0, "output": 75.0, - "cache_read": 1.5 + "cache_read": 1.5, + "cache_write": 18.75 }, "limit": { "context": 200000, - "output": 16384 + "output": 32000 } }, { "id": "llmgateway/claude-opus-4.1", "name": "Claude Opus 4.1", - "family": "claude", + "family": "claude-opus", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-03-31", "release_date": "2025-08-05", "last_updated": "2025-08-05", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -50346,7 +53051,8 @@ "cost": { "input": 15.0, "output": 75.0, - "cache_read": 1.5 + "cache_read": 1.5, + "cache_write": 18.75 }, "limit": { "context": 200000, @@ -50356,17 +53062,19 @@ { "id": "llmgateway/claude-opus-4.5", "name": "Claude Opus 4.5", - "family": "claude", + "family": "claude-opus", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-11-24", - "last_updated": "2025-11-24", + "knowledge": "2025-03-31", + "release_date": "2025-11-01", + "last_updated": "2025-11-01", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -50376,27 +53084,30 @@ "cost": { "input": 5.0, "output": 25.0, - "cache_read": 0.5 + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { "context": 200000, - "output": 32000 + "output": 64000 } }, { "id": "llmgateway/claude-opus-4.6", "name": "Claude Opus 4.6", - "family": "claude", + "family": "claude-opus", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-02-05", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -50406,7 +53117,41 @@ "cost": { "input": 5.0, "output": 25.0, - "cache_read": 0.5 + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "llmgateway/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { "context": 1000000, @@ -50415,17 +53160,20 @@ }, { "id": "llmgateway/claude-sonnet-4", - "name": "Claude Sonnet 4 (2025-05-14)", - "family": "claude", - "attachment": false, + "name": "Claude Sonnet 4", + "family": "claude-sonnet", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-05-14", - "last_updated": "2025-05-14", + "knowledge": "2025-03-31", + "release_date": "2025-05-22", + "last_updated": "2025-05-22", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50435,26 +53183,30 @@ "cost": { "input": 3.0, "output": 15.0, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { "context": 200000, - "output": 16384 + "output": 64000 } }, { "id": "llmgateway/claude-sonnet-4.5", - "name": "Claude Sonnet 4.5", - "family": "claude", - "attachment": false, + "name": "Claude Sonnet 4.5 (latest)", + "family": "claude-sonnet", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-07-31", "release_date": "2025-09-29", "last_updated": "2025-09-29", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50464,7 +53216,8 @@ "cost": { "input": 3.0, "output": 15.0, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { "context": 200000, @@ -50474,16 +53227,19 @@ { "id": "llmgateway/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", - "family": "claude", - "attachment": false, + "family": "claude-sonnet", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", - "last_updated": "2026-02-17", + "last_updated": "2026-03-13", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -50493,10 +53249,11 @@ "cost": { "input": 3.0, "output": 15.0, - "cache_read": 0.3 + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 } }, @@ -50528,35 +53285,6 @@ "output": 16384 } }, - { - "id": "llmgateway/cogview-4", - "name": "CogView-4", - "family": "glm", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-03-04", - "last_updated": "2025-03-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, { "id": "llmgateway/custom", "name": "Custom Model", @@ -50677,11 +53405,12 @@ { "id": "llmgateway/devstral", "name": "Devstral 2", - "family": "mistral", + "family": "devstral", "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, + "knowledge": "2025-12", "release_date": "2025-12-09", "last_updated": "2025-12-09", "modalities": { @@ -50699,19 +53428,20 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 262144 } }, { "id": "llmgateway/devstral-small", - "name": "Devstral Small 1.1", - "family": "mistral", + "name": "Devstral Small", + "family": "devstral", "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-07-21", - "last_updated": "2025-07-21", + "knowledge": "2025-05", + "release_date": "2025-07-10", + "last_updated": "2025-07-10", "modalities": { "input": [ "text" @@ -50726,23 +53456,28 @@ "output": 0.3 }, "limit": { - "context": 131072, - "output": 16384 + "context": 128000, + "output": 128000 } }, { "id": "llmgateway/gemini-2.0-flash", "name": "Gemini 2.0 Flash", - "family": "gemini", - "attachment": false, + "family": "gemini-flash", + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-02-05", - "last_updated": "2025-02-05", + "knowledge": "2024-06", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", "modalities": { "input": [ - "text" + "text", + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50752,7 +53487,7 @@ "cost": { "input": 0.1, "output": 0.4, - "cache_read": 0.03 + "cache_read": 0.025 }, "limit": { "context": 1048576, @@ -50762,16 +53497,21 @@ { "id": "llmgateway/gemini-2.0-flash-lite", "name": "Gemini 2.0 Flash Lite", - "family": "gemini", - "attachment": false, + "family": "gemini-flash-lite", + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-02-25", - "last_updated": "2025-02-25", + "knowledge": "2024-06", + "release_date": "2024-12-11", + "last_updated": "2024-12-11", "modalities": { "input": [ - "text" + "text", + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50779,7 +53519,7 @@ }, "open_weights": false, "cost": { - "input": 0.08, + "input": 0.075, "output": 0.3 }, "limit": { @@ -50790,17 +53530,21 @@ { "id": "llmgateway/gemini-2.5-flash", "name": "Gemini 2.5 Flash", - "family": "gemini", + "family": "gemini-flash", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-08-26", - "last_updated": "2025-08-26", + "knowledge": "2025-01", + "release_date": "2025-03-20", + "last_updated": "2025-06-05", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50814,84 +53558,27 @@ }, "limit": { "context": 1048576, - "output": 65535 - } - }, - { - "id": "llmgateway/gemini-2.5-flash-image", - "name": "Gemini 2.5 Flash Image", - "family": "gemini", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-10-02", - "last_updated": "2025-10-02", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 30.0, - "cache_read": 0.03 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "llmgateway/gemini-2.5-flash-image-preview", - "name": "Gemini 2.5 Flash Image (Preview)", - "family": "gemini", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-10-02", - "last_updated": "2025-10-02", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.3, - "output": 2.5 - }, - "limit": { - "context": 32768, - "output": 32768 + "output": 65536 } }, { "id": "llmgateway/gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash Lite", - "family": "gemini", + "family": "gemini-flash-lite", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-07-22", - "last_updated": "2025-07-22", + "knowledge": "2025-01", + "release_date": "2025-06-17", + "last_updated": "2025-06-17", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50901,27 +53588,31 @@ "cost": { "input": 0.1, "output": 0.4, - "cache_read": 0.01 + "cache_read": 0.025 }, "limit": { "context": 1048576, - "output": 65535 + "output": 65536 } }, { "id": "llmgateway/gemini-2.5-flash-lite-preview-09", - "name": "Gemini 2.5 Flash Lite Preview (09-2025)", - "family": "gemini", + "name": "Gemini 2.5 Flash Lite Preview 09-25", + "family": "gemini-flash-lite", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2025-09-25", "last_updated": "2025-09-25", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50931,27 +53622,31 @@ "cost": { "input": 0.1, "output": 0.4, - "cache_read": 0.01 + "cache_read": 0.025 }, "limit": { "context": 1048576, - "output": 65535 + "output": 65536 } }, { "id": "llmgateway/gemini-2.5-pro", "name": "Gemini 2.5 Pro", - "family": "gemini", + "family": "gemini-pro", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-03-25", - "last_updated": "2025-03-25", + "knowledge": "2025-01", + "release_date": "2025-03-20", + "last_updated": "2025-06-05", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video", + "pdf" ], "output": [ "text" @@ -50961,7 +53656,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 1048576, @@ -50970,18 +53665,22 @@ }, { "id": "llmgateway/gemini-3-flash-preview", - "name": "Gemini 3 Flash (Preview)", - "family": "gemini", + "name": "Gemini 3 Flash Preview", + "family": "gemini-flash", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2025-12-17", "last_updated": "2025-12-17", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio", + "pdf" ], "output": [ "text" @@ -50995,84 +53694,27 @@ }, "limit": { "context": 1048576, - "output": 65535 - } - }, - { - "id": "llmgateway/gemini-3-pro-image-preview", - "name": "Gemini 3 Pro Image (Preview)", - "family": "gemini", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-11-20", - "last_updated": "2025-11-20", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 12.0, - "cache_read": 0.2 - }, - "limit": { - "context": 65536, - "output": 32768 - } - }, - { - "id": "llmgateway/gemini-3.1-flash-image-preview", - "name": "Gemini 3.1 Flash Image (Preview)", - "family": "gemini", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-02-26", - "last_updated": "2026-02-26", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.25, - "output": 1.5 - }, - "limit": { - "context": 65536, "output": 65536 } }, { "id": "llmgateway/gemini-3.1-flash-lite-preview", - "name": "Gemini 3.1 Flash Lite (Preview)", - "family": "gemini", + "name": "Gemini 3.1 Flash Lite Preview", + "family": "gemini-flash-lite", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-03-03", "last_updated": "2026-03-03", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio", + "pdf" ], "output": [ "text" @@ -51082,7 +53724,8 @@ "cost": { "input": 0.25, "output": 1.5, - "cache_read": 0.03 + "cache_read": 0.025, + "cache_write": 1.0 }, "limit": { "context": 1048576, @@ -51091,18 +53734,22 @@ }, { "id": "llmgateway/gemini-3.1-pro-preview", - "name": "Gemini 3.1 Pro (Preview)", - "family": "gemini", + "name": "Gemini 3.1 Pro Preview", + "family": "gemini-pro", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-01", "release_date": "2026-02-19", "last_updated": "2026-02-19", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio", + "pdf" ], "output": [ "text" @@ -51179,17 +53826,19 @@ }, { "id": "llmgateway/gemma-3-12b-it", - "name": "Gemma 3 12B IT", + "name": "Gemma 3 12B", "family": "gemma", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", + "knowledge": "2024-10", + "release_date": "2025-03-13", + "last_updated": "2025-03-13", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -51197,12 +53846,12 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.3 + "input": 0.0, + "output": 0.0 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 32768, + "output": 8192 } }, { @@ -51264,17 +53913,19 @@ }, { "id": "llmgateway/gemma-3-4b-it", - "name": "Gemma 3 4B IT", + "name": "Gemma 3 4B", "family": "gemma", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-03-10", - "last_updated": "2025-03-10", + "knowledge": "2024-10", + "release_date": "2025-03-13", + "last_updated": "2025-03-13", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -51282,24 +53933,25 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.3 + "input": 0.0, + "output": 0.0 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 32768, + "output": 8192 } }, { "id": "llmgateway/gemma-3n-e2b-it", - "name": "Gemma 3n E2B IT", + "name": "Gemma 3n 2B", "family": "gemma", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-06-26", - "last_updated": "2025-06-26", + "knowledge": "2024-10", + "release_date": "2025-07-09", + "last_updated": "2025-07-09", "modalities": { "input": [ "text" @@ -51310,24 +53962,25 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.3 + "input": 0.0, + "output": 0.0 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 8192, + "output": 2000 } }, { "id": "llmgateway/gemma-3n-e4b-it", - "name": "Gemma 3n E4B IT", + "name": "Gemma 3n 4B", "family": "gemma", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-06-26", - "last_updated": "2025-06-26", + "knowledge": "2024-10", + "release_date": "2025-05-20", + "last_updated": "2025-05-20", "modalities": { "input": [ "text" @@ -51338,12 +53991,12 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.3 + "input": 0.0, + "output": 0.0 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 8192, + "output": 2000 } }, { @@ -51382,6 +54035,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-07-28", "last_updated": "2025-07-28", "modalities": { @@ -51392,27 +54046,29 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, "output": 2.2, - "cache_read": 0.11 + "cache_read": 0.11, + "cache_write": 0.0 }, "limit": { - "context": 128000, - "output": 16384 + "context": 131072, + "output": 98304 } }, { "id": "llmgateway/glm-4.5-air", - "name": "GLM-4.5 Air", - "family": "glm", + "name": "GLM-4.5-Air", + "family": "glm-air", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-07-25", - "last_updated": "2025-07-25", + "knowledge": "2025-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", "modalities": { "input": [ "text" @@ -51421,15 +54077,16 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.2, "output": 1.1, - "cache_read": 0.03 + "cache_read": 0.03, + "cache_write": 0.0 }, "limit": { - "context": 128000, - "output": 16384 + "context": 131072, + "output": 98304 } }, { @@ -51463,14 +54120,15 @@ }, { "id": "llmgateway/glm-4.5-flash", - "name": "GLM-4.5 Flash", - "family": "glm", + "name": "GLM-4.5-Flash", + "family": "glm-flash", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-08-13", - "last_updated": "2025-08-13", + "knowledge": "2025-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", "modalities": { "input": [ "text" @@ -51479,14 +54137,16 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, - "output": 0.0 + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 }, "limit": { - "context": 128000, - "output": 16384 + "context": 131072, + "output": 98304 } }, { @@ -51526,26 +54186,27 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-08-11", "last_updated": "2025-08-11", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, - "output": 1.8, - "cache_read": 0.11 + "output": 1.8 }, "limit": { - "context": 128000, - "output": 16000 + "context": 64000, + "output": 16384 } }, { @@ -51556,6 +54217,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-09-30", "last_updated": "2025-09-30", "modalities": { @@ -51566,15 +54228,16 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, "output": 2.2, - "cache_read": 0.11 + "cache_read": 0.11, + "cache_write": 0.0 }, "limit": { - "context": 200000, - "output": 16384 + "context": 204800, + "output": 131072 } }, { @@ -51585,26 +54248,27 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-12-08", "last_updated": "2025-12-08", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.3, - "output": 0.9, - "cache_read": 0.05 + "output": 0.9 }, "limit": { "context": 128000, - "output": 16000 + "output": 32768 } }, { @@ -51626,7 +54290,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, "output": 0.0 @@ -51674,6 +54338,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-12-22", "last_updated": "2025-12-22", "modalities": { @@ -51684,27 +54349,29 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, "output": 2.2, - "cache_read": 0.11 + "cache_read": 0.11, + "cache_write": 0.0 }, "limit": { - "context": 200000, - "output": 128000 + "context": 204800, + "output": 131072 } }, { "id": "llmgateway/glm-4.7-flash", - "name": "GLM-4.7 Flash", - "family": "glm", + "name": "GLM-4.7-Flash", + "family": "glm-flash", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-22", - "last_updated": "2025-12-22", + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", "modalities": { "input": [ "text" @@ -51713,26 +54380,29 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.0, - "output": 0.0 + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 }, "limit": { "context": 200000, - "output": 128000 + "output": 131072 } }, { "id": "llmgateway/glm-4.7-flashx", - "name": "GLM-4.7 FlashX", - "family": "glm", + "name": "GLM-4.7-FlashX", + "family": "glm-flash", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-12-22", - "last_updated": "2025-12-22", + "knowledge": "2025-04", + "release_date": "2026-01-19", + "last_updated": "2026-01-19", "modalities": { "input": [ "text" @@ -51741,15 +54411,16 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.07, "output": 0.4, - "cache_read": 0.01 + "cache_read": 0.01, + "cache_write": 0.0 }, "limit": { "context": 200000, - "output": 128000 + "output": 131072 } }, { @@ -51760,8 +54431,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-15", - "last_updated": "2026-02-15", + "release_date": "2026-02-11", + "last_updated": "2026-02-11", "modalities": { "input": [ "text" @@ -51770,56 +54441,59 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.0, "output": 3.2, - "cache_read": 0.2 + "cache_read": 0.2, + "cache_write": 0.0 }, "limit": { - "context": 202800, - "output": 131100 + "context": 204800, + "output": 131072 } }, { - "id": "llmgateway/glm-image", - "name": "GLM-Image", + "id": "llmgateway/glm-5.1", + "name": "GLM-5.1", "family": "glm", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2025-01-14", - "last_updated": "2025-01-14", + "release_date": "2026-03-27", + "last_updated": "2026-03-27", "modalities": { "input": [ "text" ], "output": [ - "text", - "image" + "text" ] }, "open_weights": false, "cost": { - "input": 0.0, - "output": 0.0 + "input": 6.0, + "output": 24.0, + "cache_read": 1.3, + "cache_write": 0.0 }, "limit": { - "context": 2000, - "output": 4096 + "context": 200000, + "output": 131072 } }, { "id": "llmgateway/gpt-3.5-turbo", - "name": "GPT-3.5 Turbo", + "name": "GPT-3.5-turbo", "family": "gpt", "attachment": false, "reasoning": false, - "tool_call": true, + "tool_call": false, "temperature": true, - "release_date": "2022-11-30", - "last_updated": "2022-11-30", + "knowledge": "2021-09-01", + "release_date": "2023-03-01", + "last_updated": "2023-11-06", "modalities": { "input": [ "text" @@ -51831,23 +54505,25 @@ "open_weights": false, "cost": { "input": 0.5, - "output": 1.5 + "output": 1.5, + "cache_read": 1.25 }, "limit": { "context": 16385, - "output": 16384 + "output": 4096 } }, { "id": "llmgateway/gpt-4", "name": "GPT-4", "family": "gpt", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2023-03-14", - "last_updated": "2023-03-14", + "knowledge": "2023-11", + "release_date": "2023-11-06", + "last_updated": "2024-04-09", "modalities": { "input": [ "text" @@ -51874,8 +54550,9 @@ "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-12", "release_date": "2023-11-06", - "last_updated": "2023-11-06", + "last_updated": "2024-04-09", "modalities": { "input": [ "text", @@ -51892,7 +54569,7 @@ }, "limit": { "context": 128000, - "output": 16384 + "output": 4096 } }, { @@ -51903,12 +54580,14 @@ "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -51921,24 +54600,26 @@ "cache_read": 0.5 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 1047576, + "output": 32768 } }, { "id": "llmgateway/gpt-4.1-mini", - "name": "GPT-4.1 Mini", - "family": "gpt", + "name": "GPT-4.1 mini", + "family": "gpt-mini", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -51951,18 +54632,19 @@ "cache_read": 0.1 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 1047576, + "output": 32768 } }, { "id": "llmgateway/gpt-4.1-nano", - "name": "GPT-4.1 Nano", - "family": "gpt", + "name": "GPT-4.1 nano", + "family": "gpt-nano", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2024-04", "release_date": "2025-04-14", "last_updated": "2025-04-14", "modalities": { @@ -51981,8 +54663,8 @@ "cache_read": 0.03 }, "limit": { - "context": 1000000, - "output": 16384 + "context": 1047576, + "output": 32768 } }, { @@ -51993,12 +54675,14 @@ "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-09", "release_date": "2024-05-13", - "last_updated": "2024-05-13", + "last_updated": "2024-08-06", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -52017,17 +54701,20 @@ }, { "id": "llmgateway/gpt-4o-mini", - "name": "GPT-4o Mini", - "family": "gpt", - "attachment": false, + "name": "GPT-4o mini", + "family": "gpt-mini", + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-09", "release_date": "2024-07-18", "last_updated": "2024-07-18", "modalities": { "input": [ - "text" + "text", + "image", + "pdf" ], "output": [ "text" @@ -52109,9 +54796,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", "modalities": { "input": [ "text", @@ -52125,7 +54813,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.13 + "cache_read": 0.125 }, "limit": { "context": 400000, @@ -52134,14 +54822,15 @@ }, { "id": "llmgateway/gpt-5-chat", - "name": "GPT-5 Chat Latest", - "family": "gpt", + "name": "GPT-5 Chat (latest)", + "family": "gpt-codex", "attachment": true, - "reasoning": false, + "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", "modalities": { "input": [ "text", @@ -52154,8 +54843,7 @@ "open_weights": false, "cost": { "input": 1.25, - "output": 10.0, - "cache_read": 0.13 + "output": 10.0 }, "limit": { "context": 400000, @@ -52165,13 +54853,14 @@ { "id": "llmgateway/gpt-5-mini", "name": "GPT-5 Mini", - "family": "gpt", + "family": "gpt-mini", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", "modalities": { "input": [ "text", @@ -52185,7 +54874,7 @@ "cost": { "input": 0.25, "output": 2.0, - "cache_read": 0.03 + "cache_read": 0.025 }, "limit": { "context": 400000, @@ -52195,16 +54884,18 @@ { "id": "llmgateway/gpt-5-nano", "name": "GPT-5 Nano", - "family": "gpt", - "attachment": false, + "family": "gpt-nano", + "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", + "temperature": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -52214,7 +54905,7 @@ "cost": { "input": 0.05, "output": 0.4, - "cache_read": 0.01 + "cache_read": 0.005 }, "limit": { "context": 400000, @@ -52224,13 +54915,14 @@ { "id": "llmgateway/gpt-5-pro", "name": "GPT-5 Pro", - "family": "gpt", + "family": "gpt-pro", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-08-01", - "last_updated": "2025-08-01", + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-10-06", + "last_updated": "2025-10-06", "modalities": { "input": [ "text", @@ -52257,9 +54949,10 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-11-01", - "last_updated": "2025-11-01", + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", "modalities": { "input": [ "text", @@ -52283,11 +54976,12 @@ { "id": "llmgateway/gpt-5.1-codex", "name": "GPT-5.1 Codex", - "family": "gpt", + "family": "gpt-codex", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2024-09-30", "release_date": "2025-11-13", "last_updated": "2025-11-13", "modalities": { @@ -52302,23 +54996,25 @@ "open_weights": false, "cost": { "input": 1.25, - "output": 10.0 + "output": 10.0, + "cache_read": 0.125 }, "limit": { "context": 400000, - "output": 272000 + "output": 128000 } }, { "id": "llmgateway/gpt-5.1-codex-mini", "name": "GPT-5.1 Codex mini", - "family": "gpt", + "family": "gpt-codex", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2025-11-12", - "last_updated": "2025-11-12", + "temperature": false, + "knowledge": "2024-09-30", + "release_date": "2025-11-13", + "last_updated": "2025-11-13", "modalities": { "input": [ "text", @@ -52332,7 +55028,7 @@ "cost": { "input": 0.25, "output": 2.0, - "cache_read": 0.03 + "cache_read": 0.025 }, "limit": { "context": 400000, @@ -52346,7 +55042,8 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", "modalities": { @@ -52362,7 +55059,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 400000, @@ -52372,11 +55069,12 @@ { "id": "llmgateway/gpt-5.2-chat", "name": "GPT-5.2 Chat", - "family": "gpt", + "family": "gpt-codex", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", "modalities": { @@ -52392,27 +55090,29 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 128000, - "output": 16400 + "output": 16384 } }, { "id": "llmgateway/gpt-5.2-codex", "name": "GPT-5.2 Codex", - "family": "gpt", + "family": "gpt-codex", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-01-14", - "last_updated": "2026-01-14", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -52422,7 +55122,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 400000, @@ -52432,11 +55132,12 @@ { "id": "llmgateway/gpt-5.2-pro", "name": "GPT-5.2 Pro", - "family": "gpt", + "family": "gpt-pro", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", "modalities": { @@ -52455,17 +55156,18 @@ }, "limit": { "context": 400000, - "output": 272000 + "output": 128000 } }, { "id": "llmgateway/gpt-5.3-chat", - "name": "GPT-5.3 Chat", + "name": "GPT-5.3 Chat (latest)", "family": "gpt", "attachment": true, - "reasoning": true, + "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-03-03", "last_updated": "2026-03-03", "modalities": { @@ -52481,7 +55183,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 128000, @@ -52491,17 +55193,19 @@ { "id": "llmgateway/gpt-5.3-codex", "name": "GPT-5.3 Codex", - "family": "gpt", + "family": "gpt-codex", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-02-24", - "last_updated": "2026-02-24", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -52511,7 +55215,7 @@ "cost": { "input": 1.75, "output": 14.0, - "cache_read": 0.18 + "cache_read": 0.175 }, "limit": { "context": 400000, @@ -52525,13 +55229,15 @@ "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-03-06", - "last_updated": "2026-03-06", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -52550,12 +55256,13 @@ }, { "id": "llmgateway/gpt-5.4-mini", - "name": "GPT-5.4 Mini", - "family": "gpt", + "name": "GPT-5.4 mini", + "family": "gpt-mini", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", "modalities": { @@ -52571,7 +55278,7 @@ "cost": { "input": 0.75, "output": 4.5, - "cache_read": 0.08 + "cache_read": 0.075 }, "limit": { "context": 400000, @@ -52580,12 +55287,13 @@ }, { "id": "llmgateway/gpt-5.4-nano", - "name": "GPT-5.4 Nano", - "family": "gpt", + "name": "GPT-5.4 nano", + "family": "gpt-nano", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2025-08-31", "release_date": "2026-03-17", "last_updated": "2026-03-17", "modalities": { @@ -52611,13 +55319,14 @@ { "id": "llmgateway/gpt-5.4-pro", "name": "GPT-5.4 Pro", - "family": "gpt", + "family": "gpt-pro", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-03-01", - "last_updated": "2026-03-01", + "temperature": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", "modalities": { "input": [ "text", @@ -52695,12 +55404,13 @@ }, { "id": "llmgateway/grok-3", - "name": "Grok-3", + "name": "Grok 3", "family": "grok", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2024-11", "release_date": "2025-02-17", "last_updated": "2025-02-17", "modalities": { @@ -52714,21 +55424,23 @@ "open_weights": false, "cost": { "input": 3.0, - "output": 15.0 + "output": 15.0, + "cache_read": 0.75 }, "limit": { "context": 131072, - "output": 16384 + "output": 8192 } }, { "id": "llmgateway/grok-4", - "name": "Grok 4 (0709)", + "name": "Grok 4", "family": "grok", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-07", "release_date": "2025-07-09", "last_updated": "2025-07-09", "modalities": { @@ -52742,16 +55454,17 @@ "open_weights": false, "cost": { "input": 3.0, - "output": 15.0 + "output": 15.0, + "cache_read": 0.75 }, "limit": { "context": 256000, - "output": 256000 + "output": 64000 } }, { "id": "llmgateway/grok-4-20-beta", - "name": "Grok 4.20 Beta Reasoning (0309)", + "name": "Grok 4.20 (Reasoning)", "family": "grok", "attachment": true, "reasoning": true, @@ -52781,7 +55494,7 @@ }, { "id": "llmgateway/grok-4-20-beta-0309-non", - "name": "Grok 4.20 Beta Non-Reasoning (0309)", + "name": "Grok 4.20 (Non-Reasoning)", "family": "grok", "attachment": true, "reasoning": false, @@ -52809,46 +55522,17 @@ "output": 30000 } }, - { - "id": "llmgateway/grok-4-20-multi-agent-beta", - "name": "Grok 4.20 Multi-Agent Beta (0309)", - "family": "grok", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2026-03-09", - "last_updated": "2026-03-09", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 2.0, - "output": 6.0, - "cache_read": 0.2 - }, - "limit": { - "context": 2000000, - "output": 30000 - } - }, { "id": "llmgateway/grok-4-fast", - "name": "Grok 4 Fast Reasoning", + "name": "Grok 4 Fast", "family": "grok", "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-07-09", - "last_updated": "2025-07-09", + "knowledge": "2025-07", + "release_date": "2025-09-19", + "last_updated": "2025-09-19", "modalities": { "input": [ "text", @@ -52871,14 +55555,15 @@ }, { "id": "llmgateway/grok-4-fast-non", - "name": "Grok 4 Fast Non-Reasoning", + "name": "Grok 4 Fast (Non-Reasoning)", "family": "grok", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-10-10", - "last_updated": "2025-10-10", + "knowledge": "2025-07", + "release_date": "2025-09-19", + "last_updated": "2025-09-19", "modalities": { "input": [ "text", @@ -52907,6 +55592,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-07", "release_date": "2025-11-19", "last_updated": "2025-11-19", "modalities": { @@ -52931,12 +55617,13 @@ }, { "id": "llmgateway/grok-4.1-fast-non", - "name": "Grok 4.1 Fast Non-Reasoning", + "name": "Grok 4.1 Fast (Non-Reasoning)", "family": "grok", "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2025-07", "release_date": "2025-11-19", "last_updated": "2025-11-19", "modalities": { @@ -52964,9 +55651,10 @@ "name": "Grok Code Fast 1", "family": "grok", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2023-10", "release_date": "2025-08-28", "last_updated": "2025-08-28", "modalities": { @@ -52980,77 +55668,18 @@ "open_weights": false, "cost": { "input": 0.2, - "output": 1.5 + "output": 1.5, + "cache_read": 0.02 }, "limit": { "context": 256000, "output": 10000 } }, - { - "id": "llmgateway/grok-imagine-image", - "name": "Grok Imagine Image", - "family": "grok", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-03-02", - "last_updated": "2026-03-02", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/grok-imagine-image-pro", - "name": "Grok Imagine Image Pro", - "family": "grok", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-03-02", - "last_updated": "2026-03-02", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, { "id": "llmgateway/hermes-2-pro-llama-3-8b", "name": "Hermes 2 Pro Llama 3 8B", - "family": "nousresearch", + "family": "hermes", "attachment": false, "reasoning": false, "tool_call": false, @@ -53093,7 +55722,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.0, "output": 3.0, @@ -53107,11 +55736,12 @@ { "id": "llmgateway/kimi-k2-thinking", "name": "Kimi K2 Thinking", - "family": "kimi", + "family": "kimi-thinking", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-08", "release_date": "2025-11-06", "last_updated": "2025-11-06", "modalities": { @@ -53122,7 +55752,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, "output": 2.5, @@ -53136,11 +55766,12 @@ { "id": "llmgateway/kimi-k2-thinking-turbo", "name": "Kimi K2 Thinking Turbo", - "family": "kimi", + "family": "kimi-thinking", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2024-08", "release_date": "2025-11-06", "last_updated": "2025-11-06", "modalities": { @@ -53151,7 +55782,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 1.15, "output": 8.0, @@ -53165,23 +55796,25 @@ { "id": "llmgateway/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", - "attachment": true, + "family": "kimi-k2.5", + "attachment": false, "reasoning": true, "tool_call": true, - "temperature": true, - "release_date": "2026-01-26", - "last_updated": "2026-01-26", + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.6, "output": 3.0, @@ -53189,7 +55822,7 @@ }, "limit": { "context": 262144, - "output": 32768 + "output": 262144 } }, { @@ -53329,7 +55962,7 @@ }, "limit": { "context": 128000, - "output": 16384 + "output": 8192 } }, { @@ -53357,7 +55990,7 @@ }, "limit": { "context": 128000, - "output": 16384 + "output": 8192 } }, { @@ -53390,12 +56023,13 @@ }, { "id": "llmgateway/llama-3.3-70b-instruct", - "name": "Llama 3.3 70B Instruct", + "name": "Llama-3.3-70B-Instruct", "family": "llama", - "attachment": false, + "attachment": true, "reasoning": false, "tool_call": true, "temperature": true, + "knowledge": "2023-12", "release_date": "2024-12-06", "last_updated": "2024-12-06", "modalities": { @@ -53408,12 +56042,12 @@ }, "open_weights": true, "cost": { - "input": 0.13, - "output": 0.4 + "input": 0.0, + "output": 0.0 }, "limit": { "context": 128000, - "output": 16384 + "output": 4096 } }, { @@ -53503,15 +56137,16 @@ } }, { - "id": "llmgateway/llama-guard-4-12b", - "name": "Llama Guard 4 12B", - "family": "llama", + "id": "llmgateway/mimo-v2-flash", + "name": "MiMo-V2-Flash", + "family": "mimo", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2025-04-30", - "last_updated": "2025-04-30", + "knowledge": "2024-12-01", + "release_date": "2025-12-16", + "last_updated": "2026-02-04", "modalities": { "input": [ "text" @@ -53522,17 +56157,18 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.1, + "output": 0.3, + "cache_read": 0.01 }, "limit": { - "context": 131072, - "output": 16384 + "context": 256000, + "output": 64000 } }, { "id": "llmgateway/minimax-m2", - "name": "MiniMax M2", + "name": "MiniMax-M2", "family": "minimax", "attachment": false, "reasoning": true, @@ -53550,18 +56186,17 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 1.0, - "cache_read": 0.03 + "input": 0.3, + "output": 1.2 }, "limit": { "context": 196608, - "output": 131072 + "output": 128000 } }, { "id": "llmgateway/minimax-m2.1", - "name": "MiniMax M2.1", + "name": "MiniMax-M2.1", "family": "minimax", "attachment": false, "reasoning": true, @@ -53579,11 +56214,11 @@ }, "open_weights": true, "cost": { - "input": 0.27, - "output": 1.1 + "input": 0.3, + "output": 1.2 }, "limit": { - "context": 196608, + "context": 204800, "output": 131072 } }, @@ -53617,14 +56252,14 @@ }, { "id": "llmgateway/minimax-m2.5", - "name": "MiniMax M2.5", + "name": "MiniMax-M2.5", "family": "minimax", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2026-02-15", - "last_updated": "2026-02-15", + "release_date": "2026-02-12", + "last_updated": "2026-02-12", "modalities": { "input": [ "text" @@ -53637,23 +56272,24 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.03 + "cache_read": 0.03, + "cache_write": 0.375 }, "limit": { "context": 204800, - "output": 131100 + "output": 131072 } }, { "id": "llmgateway/minimax-m2.5-highspeed", - "name": "MiniMax M2.5 Highspeed", + "name": "MiniMax-M2.5-highspeed", "family": "minimax", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2024-01-01", - "last_updated": "2024-01-01", + "release_date": "2026-02-13", + "last_updated": "2026-02-13", "modalities": { "input": [ "text" @@ -53666,23 +56302,24 @@ "cost": { "input": 0.6, "output": 2.4, - "cache_read": 0.03 + "cache_read": 0.06, + "cache_write": 0.375 }, "limit": { "context": 204800, - "output": 131100 + "output": 131072 } }, { "id": "llmgateway/minimax-m2.7", - "name": "MiniMax M2.7", + "name": "MiniMax-M2.7", "family": "minimax", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2024-01-01", - "last_updated": "2024-01-01", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "modalities": { "input": [ "text" @@ -53695,23 +56332,24 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.06 + "cache_read": 0.06, + "cache_write": 0.375 }, "limit": { "context": 204800, - "output": 131100 + "output": 131072 } }, { "id": "llmgateway/minimax-m2.7-highspeed", - "name": "MiniMax M2.7 Highspeed", + "name": "MiniMax-M2.7-highspeed", "family": "minimax", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2024-01-01", - "last_updated": "2024-01-01", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", "modalities": { "input": [ "text" @@ -53724,11 +56362,12 @@ "cost": { "input": 0.6, "output": 2.4, - "cache_read": 0.06 + "cache_read": 0.06, + "cache_write": 0.375 }, "limit": { "context": 204800, - "output": 131100 + "output": 131072 } }, { @@ -53736,7 +56375,7 @@ "name": "MiniMax Text 01", "family": "minimax", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": false, "temperature": true, "release_date": "2025-01-15", @@ -53785,7 +56424,7 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 8192 } }, { @@ -53814,7 +56453,7 @@ }, "limit": { "context": 131072, - "output": 16384 + "output": 8192 } }, { @@ -53843,22 +56482,24 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 8192 } }, { "id": "llmgateway/mistral-large", - "name": "Mistral Large Latest", - "family": "mistral", - "attachment": false, + "name": "Mistral Large (latest)", + "family": "mistral-large", + "attachment": true, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-12-02", + "knowledge": "2024-11", + "release_date": "2024-11-01", "last_updated": "2025-12-02", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -53866,22 +56507,23 @@ }, "open_weights": true, "cost": { - "input": 4.0, - "output": 12.0 + "input": 0.5, + "output": 1.5 }, "limit": { - "context": 128000, - "output": 16384 + "context": 262144, + "output": 262144 } }, { "id": "llmgateway/mistral-small", "name": "Mistral Small 3.2", - "family": "mistral", - "attachment": true, + "family": "mistral-small", + "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, + "knowledge": "2025-03", "release_date": "2025-06-20", "last_updated": "2025-06-20", "modalities": { @@ -53903,48 +56545,22 @@ "output": 16384 } }, - { - "id": "llmgateway/mixtral-8x7b-instruct-together", - "name": "Mixtral 8x7B Instruct", - "family": "mistral", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2023-12-10", - "last_updated": "2023-12-10", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.06, - "output": 0.06 - }, - "limit": { - "context": 32768, - "output": 16384 - } - }, { "id": "llmgateway/o1", "name": "o1", - "family": "gpt", + "family": "o", "attachment": true, "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2024-09-12", - "last_updated": "2024-09-12", + "tool_call": true, + "temperature": false, + "knowledge": "2023-09", + "release_date": "2024-12-05", + "last_updated": "2024-12-05", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -53958,23 +56574,25 @@ }, "limit": { "context": 200000, - "output": 16384 + "output": 100000 } }, { "id": "llmgateway/o3", "name": "o3", - "family": "gpt", + "family": "o", "attachment": true, "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-06-01", - "last_updated": "2025-06-01", + "tool_call": true, + "temperature": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -53988,19 +56606,20 @@ }, "limit": { "context": 200000, - "output": 16384 + "output": 100000 } }, { "id": "llmgateway/o3-mini", - "name": "o3 Mini", - "family": "gpt", + "name": "o3-mini", + "family": "o-mini", "attachment": false, "reasoning": true, - "tool_call": false, - "temperature": true, - "release_date": "2025-06-01", - "last_updated": "2025-06-01", + "tool_call": true, + "temperature": false, + "knowledge": "2024-05", + "release_date": "2024-12-20", + "last_updated": "2025-01-29", "modalities": { "input": [ "text" @@ -54017,17 +56636,18 @@ }, "limit": { "context": 200000, - "output": 16384 + "output": 100000 } }, { "id": "llmgateway/o4-mini", - "name": "o4 Mini", - "family": "gpt", + "name": "o4-mini", + "family": "o-mini", "attachment": true, "reasoning": true, "tool_call": true, - "temperature": true, + "temperature": false, + "knowledge": "2024-05", "release_date": "2025-04-16", "last_updated": "2025-04-16", "modalities": { @@ -54047,19 +56667,20 @@ }, "limit": { "context": 200000, - "output": 16384 + "output": 100000 } }, { "id": "llmgateway/pixtral-large", - "name": "Pixtral Large Latest", - "family": "mistral", + "name": "Pixtral Large (latest)", + "family": "pixtral", "attachment": true, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2024-11-18", - "last_updated": "2024-11-18", + "knowledge": "2024-11", + "release_date": "2024-11-01", + "last_updated": "2024-11-04", "modalities": { "input": [ "text", @@ -54071,12 +56692,12 @@ }, "open_weights": true, "cost": { - "input": 4.0, - "output": 12.0 + "input": 2.0, + "output": 6.0 }, "limit": { "context": 128000, - "output": 16384 + "output": 128000 } }, { @@ -54097,10 +56718,10 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 1.0, - "output": 5.0 + "input": 0.5, + "output": 1.0 }, "limit": { "context": 131072, @@ -54112,11 +56733,12 @@ "name": "Qwen Flash", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2024-09-09", - "last_updated": "2024-09-09", + "knowledge": "2024-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", "modalities": { "input": [ "text" @@ -54125,232 +56747,89 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.05, - "output": 0.4, - "cache_read": 0.01 + "output": 0.4 }, "limit": { "context": 1000000, - "output": 32000 - } - }, - { - "id": "llmgateway/qwen-image", - "name": "Qwen Image", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-04", - "last_updated": "2025-08-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/qwen-image-edit-max", - "name": "Qwen Image Edit Max", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-01-16", - "last_updated": "2026-01-16", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/qwen-image-edit-plus", - "name": "Qwen Image Edit Plus", - "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-19", - "last_updated": "2025-08-19", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/qwen-image-max", - "name": "Qwen Image Max 2025-12-30", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-31", - "last_updated": "2025-12-31", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/qwen-image-plus", - "name": "Qwen Image Plus", - "family": "qwen", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-08-04", - "last_updated": "2025-08-04", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": true, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 + "output": 32768 } }, { "id": "llmgateway/qwen-max", - "name": "Qwen Max Latest", + "name": "Qwen Max", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-01-25", + "knowledge": "2024-04", + "release_date": "2024-04-03", "last_updated": "2025-01-25", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 1.6, "output": 6.4 }, "limit": { - "context": 131072, - "output": 32000 + "context": 32768, + "output": 8192 } }, { "id": "llmgateway/qwen-omni-turbo", - "name": "Qwen Omni Turbo", + "name": "Qwen-Omni Turbo", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-03-26", + "knowledge": "2024-04", + "release_date": "2025-01-19", "last_updated": "2025-03-26", "modalities": { "input": [ "text", - "image" + "image", + "audio", + "video" ], "output": [ - "text" + "text", + "audio" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.2, - "output": 0.8 + "input": 0.07, + "output": 0.27 }, "limit": { "context": 32768, - "output": 8192 + "output": 2048 } }, { "id": "llmgateway/qwen-plus", - "name": "Qwen Plus Latest", + "name": "Qwen Plus", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2024-09-09", - "last_updated": "2024-09-09", + "knowledge": "2024-04", + "release_date": "2024-01-25", + "last_updated": "2025-09-11", "modalities": { "input": [ "text" @@ -54359,15 +56838,14 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.4, - "output": 1.2, - "cache_read": 0.08 + "output": 1.2 }, "limit": { "context": 1000000, - "output": 32000 + "output": 32768 } }, { @@ -54375,11 +56853,12 @@ "name": "Qwen Turbo", "family": "qwen", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, - "release_date": "2025-02-01", - "last_updated": "2025-02-01", + "knowledge": "2024-04", + "release_date": "2024-11-01", + "last_updated": "2025-04-28", "modalities": { "input": [ "text" @@ -54388,26 +56867,27 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.05, "output": 0.2 }, "limit": { "context": 1000000, - "output": 8192 + "output": 16384 } }, { "id": "llmgateway/qwen-vl-max", - "name": "Qwen VL Max", + "name": "Qwen-VL Max", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-02-01", - "last_updated": "2025-02-01", + "knowledge": "2024-04", + "release_date": "2024-04-08", + "last_updated": "2025-08-13", "modalities": { "input": [ "text", @@ -54417,26 +56897,27 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.8, "output": 3.2 }, "limit": { "context": 131072, - "output": 32000 + "output": 8192 } }, { "id": "llmgateway/qwen-vl-plus", - "name": "Qwen VL Plus", + "name": "Qwen-VL Plus", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-02-05", - "last_updated": "2025-02-05", + "knowledge": "2024-04", + "release_date": "2024-01-25", + "last_updated": "2025-08-15", "modalities": { "input": [ "text", @@ -54446,14 +56927,14 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.21, - "output": 0.64 + "output": 0.63 }, "limit": { "context": 131072, - "output": 32000 + "output": 8192 } }, { @@ -54462,10 +56943,10 @@ "family": "qwen", "attachment": true, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-02-19", - "last_updated": "2025-02-19", + "release_date": "2025-03-15", + "last_updated": "2025-03-15", "modalities": { "input": [ "text", @@ -54477,24 +56958,25 @@ }, "open_weights": true, "cost": { - "input": 1.4, - "output": 4.2 + "input": 0.3, + "output": 0.3 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { "id": "llmgateway/qwen2-5-vl-72b-instruct", - "name": "Qwen2.5 VL 72B Instruct", + "name": "Qwen2.5-VL 72B Instruct", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": false, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-01-26", - "last_updated": "2025-01-26", + "knowledge": "2024-04", + "release_date": "2024-09", + "last_updated": "2024-09", "modalities": { "input": [ "text", @@ -54506,11 +56988,11 @@ }, "open_weights": true, "cost": { - "input": 0.13, - "output": 0.4 + "input": 2.8, + "output": 8.4 }, "limit": { - "context": 32768, + "context": 131072, "output": 8192 } }, @@ -54534,11 +57016,11 @@ }, "open_weights": true, "cost": { - "input": 0.01, - "output": 0.03 + "input": 0.05, + "output": 0.05 }, "limit": { - "context": 32768, + "context": 131072, "output": 8192 } }, @@ -54547,8 +57029,8 @@ "name": "Qwen3 235B A22B FP8", "family": "qwen", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, "release_date": "2025-04-28", "last_updated": "2025-04-28", @@ -54562,24 +57044,24 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.8 + "input": 0.5, + "output": 2.5 }, "limit": { - "context": 40960, - "output": 20000 + "context": 131072, + "output": 8192 } }, { "id": "llmgateway/qwen3-235b-a22b-instruct", - "name": "Qwen3 235B A22B Instruct 2507", + "name": "Qwen3 235B A22B Instruct (2507)", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-07-21", - "last_updated": "2025-07-21", + "release_date": "2025-07-08", + "last_updated": "2025-07-08", "modalities": { "input": [ "text" @@ -54590,24 +57072,24 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.6 + "input": 0.8, + "output": 2.4 }, "limit": { - "context": 262000, + "context": 131072, "output": 8192 } }, { "id": "llmgateway/qwen3-235b-a22b-thinking", - "name": "Qwen3 235B A22B Thinking 2507", + "name": "Qwen3 235B A22B Thinking (2507)", "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-07-25", - "last_updated": "2025-07-25", + "release_date": "2025-07-08", + "last_updated": "2025-07-08", "modalities": { "input": [ "text" @@ -54618,11 +57100,11 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.6 + "input": 0.8, + "output": 2.4 }, "limit": { - "context": 262000, + "context": 131072, "output": 8192 } }, @@ -54631,8 +57113,8 @@ "name": "Qwen3 30B A3B FP8", "family": "qwen", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, "release_date": "2025-04-28", "last_updated": "2025-04-28", @@ -54646,24 +57128,24 @@ }, "open_weights": true, "cost": { - "input": 0.09, - "output": 0.45 + "input": 0.1, + "output": 0.1 }, "limit": { - "context": 40960, - "output": 20000 + "context": 131072, + "output": 8192 } }, { "id": "llmgateway/qwen3-30b-a3b-instruct", - "name": "Qwen3 30B A3B Instruct 2507", + "name": "Qwen3 30B A3B Instruct (2507)", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-07-30", - "last_updated": "2025-07-30", + "release_date": "2025-07-08", + "last_updated": "2025-07-08", "modalities": { "input": [ "text" @@ -54675,23 +57157,23 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.3 + "output": 0.1 }, "limit": { - "context": 262000, + "context": 131072, "output": 8192 } }, { "id": "llmgateway/qwen3-30b-a3b-thinking", - "name": "Qwen3 30B A3B Thinking 2507", + "name": "Qwen3 30B A3B Thinking (2507)", "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-07-30", - "last_updated": "2025-07-30", + "release_date": "2025-07-08", + "last_updated": "2025-07-08", "modalities": { "input": [ "text" @@ -54703,10 +57185,10 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.3 + "output": 0.1 }, "limit": { - "context": 262000, + "context": 131072, "output": 8192 } }, @@ -54715,11 +57197,12 @@ "name": "Qwen3 32B", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-04-28", - "last_updated": "2025-04-28", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -54730,12 +57213,12 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.7, + "output": 2.8 }, "limit": { - "context": 32768, - "output": 8192 + "context": 131072, + "output": 16384 } }, { @@ -54743,8 +57226,8 @@ "name": "Qwen3 32B FP8", "family": "qwen", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, "release_date": "2025-04-28", "last_updated": "2025-04-28", @@ -54759,11 +57242,11 @@ "open_weights": true, "cost": { "input": 0.1, - "output": 0.45 + "output": 0.1 }, "limit": { - "context": 40960, - "output": 20000 + "context": 131072, + "output": 8192 } }, { @@ -54771,8 +57254,8 @@ "name": "Qwen3 4B FP8", "family": "qwen", "attachment": false, - "reasoning": false, - "tool_call": false, + "reasoning": true, + "tool_call": true, "temperature": true, "release_date": "2025-04-28", "last_updated": "2025-04-28", @@ -54787,23 +57270,24 @@ "open_weights": true, "cost": { "input": 0.03, - "output": 0.03 + "output": 0.05 }, "limit": { - "context": 128000, - "output": 20000 + "context": 131072, + "output": 8192 } }, { "id": "llmgateway/qwen3-coder-30b-a3b-instruct", - "name": "Qwen3 Coder 30B A3B Instruct", + "name": "Qwen3-Coder 30B-A3B Instruct", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-07-31", - "last_updated": "2025-07-31", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -54814,24 +57298,25 @@ }, "open_weights": true, "cost": { - "input": 0.1, - "output": 0.3 + "input": 0.45, + "output": 2.25 }, "limit": { - "context": 262000, - "output": 8192 + "context": 262144, + "output": 65536 } }, { "id": "llmgateway/qwen3-coder-480b-a35b-instruct", - "name": "Qwen3 Coder 480B A35B Instruct", + "name": "Qwen3-Coder 480B-A35B Instruct", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-01-31", - "last_updated": "2025-01-31", + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", "modalities": { "input": [ "text" @@ -54842,12 +57327,12 @@ }, "open_weights": true, "cost": { - "input": 0.4, - "output": 1.8 + "input": 1.5, + "output": 7.5 }, "limit": { - "context": 262000, - "output": 8192 + "context": 262144, + "output": 65536 } }, { @@ -54858,8 +57343,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-07-22", - "last_updated": "2025-07-22", + "knowledge": "2025-04", + "release_date": "2025-07-28", + "last_updated": "2025-07-28", "modalities": { "input": [ "text" @@ -54868,11 +57354,10 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.3, - "output": 1.5, - "cache_read": 0.06 + "output": 1.5 }, "limit": { "context": 1000000, @@ -54884,11 +57369,11 @@ "name": "Qwen3 Coder Next", "family": "qwen", "attachment": false, - "reasoning": false, + "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2024-01-01", - "last_updated": "2024-01-01", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", "modalities": { "input": [ "text" @@ -54897,15 +57382,14 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { - "input": 0.11, - "output": 0.68, - "cache_read": 0.06 + "input": 0.8, + "output": 4.0 }, "limit": { "context": 262144, - "output": 262144 + "output": 65536 } }, { @@ -54916,8 +57400,9 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-09-23", - "last_updated": "2025-09-23", + "knowledge": "2025-04", + "release_date": "2025-07-23", + "last_updated": "2025-07-23", "modalities": { "input": [ "text" @@ -54928,38 +57413,37 @@ }, "open_weights": true, "cost": { - "input": 6.0, - "output": 60.0 + "input": 1.0, + "output": 5.0 }, "limit": { - "context": 1000000, - "output": 66000 + "context": 1048576, + "output": 65536 } }, { "id": "llmgateway/qwen3-max", - "name": "Qwen3 Max 2026-01-23", + "name": "Qwen3 Max", "family": "qwen", - "attachment": true, - "reasoning": true, + "attachment": false, + "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2026-01-23", - "last_updated": "2026-01-23", + "knowledge": "2025-04", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 1.2, - "output": 6.0, - "cache_read": 0.24 + "output": 6.0 }, "limit": { "context": 262144, @@ -54968,14 +57452,15 @@ }, { "id": "llmgateway/qwen3-next-80b-a3b-instruct", - "name": "Qwen3 Next 80B A3B Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", "family": "qwen", "attachment": false, "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-09-10", - "last_updated": "2025-09-10", + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -54990,20 +57475,21 @@ "output": 2.0 }, "limit": { - "context": 129024, + "context": 131072, "output": 32768 } }, { "id": "llmgateway/qwen3-next-80b-a3b-thinking", - "name": "Qwen3 Next 80B A3B Thinking", + "name": "Qwen3-Next 80B-A3B (Thinking)", "family": "qwen", "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-09-10", - "last_updated": "2025-09-10", + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", "modalities": { "input": [ "text" @@ -55030,8 +57516,8 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-09-23", - "last_updated": "2025-09-23", + "release_date": "2025-09-15", + "last_updated": "2025-09-15", "modalities": { "input": [ "text", @@ -55043,12 +57529,12 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.8, + "output": 2.4 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { @@ -55057,10 +57543,10 @@ "family": "qwen", "attachment": true, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-09-23", - "last_updated": "2025-09-23", + "release_date": "2025-09-15", + "last_updated": "2025-09-15", "modalities": { "input": [ "text", @@ -55072,12 +57558,12 @@ }, "open_weights": true, "cost": { - "input": 0.5, - "output": 2.0 + "input": 0.8, + "output": 2.4 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { @@ -55088,8 +57574,8 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-10-05", - "last_updated": "2025-10-05", + "release_date": "2025-10-02", + "last_updated": "2025-10-02", "modalities": { "input": [ "text", @@ -55101,12 +57587,12 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 0.7 + "input": 0.1, + "output": 0.1 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { @@ -55117,8 +57603,8 @@ "reasoning": true, "tool_call": true, "temperature": true, - "release_date": "2025-10-11", - "last_updated": "2025-10-11", + "release_date": "2025-10-02", + "last_updated": "2025-10-02", "modalities": { "input": [ "text", @@ -55130,12 +57616,12 @@ }, "open_weights": true, "cost": { - "input": 0.2, - "output": 1.0 + "input": 0.1, + "output": 0.1 }, "limit": { "context": 131072, - "output": 32768 + "output": 8192 } }, { @@ -55146,8 +57632,8 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-10-14", - "last_updated": "2025-10-14", + "release_date": "2025-08-19", + "last_updated": "2025-08-19", "modalities": { "input": [ "text", @@ -55159,8 +57645,8 @@ }, "open_weights": true, "cost": { - "input": 0.08, - "output": 0.5 + "input": 0.1, + "output": 0.1 }, "limit": { "context": 131072, @@ -55175,8 +57661,8 @@ "reasoning": false, "tool_call": true, "temperature": true, - "release_date": "2025-10-15", - "last_updated": "2025-10-15", + "release_date": "2025-10-09", + "last_updated": "2025-10-09", "modalities": { "input": [ "text", @@ -55186,25 +57672,26 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.05, "output": 0.4, "cache_read": 0.01 }, "limit": { - "context": 262144, - "output": 32768 + "context": 1000000, + "output": 32000 } }, { "id": "llmgateway/qwen3-vl-plus", - "name": "Qwen3 VL Plus", + "name": "Qwen3-VL Plus", "family": "qwen", - "attachment": true, - "reasoning": false, - "tool_call": false, + "attachment": false, + "reasoning": true, + "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2025-09-23", "last_updated": "2025-09-23", "modalities": { @@ -55216,11 +57703,10 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.2, - "output": 1.6, - "cache_read": 0.04 + "output": 1.6 }, "limit": { "context": 262144, @@ -55229,18 +57715,20 @@ }, { "id": "llmgateway/qwen35-397b-a17b", - "name": "Qwen3.5 397B A17B", + "name": "Qwen3.5 397B-A17B", "family": "qwen", - "attachment": true, + "attachment": false, "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-04", "release_date": "2026-02-16", "last_updated": "2026-02-16", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -55262,10 +57750,11 @@ "family": "qwen", "attachment": false, "reasoning": true, - "tool_call": false, + "tool_call": true, "temperature": true, - "release_date": "2025-03-06", - "last_updated": "2025-03-06", + "knowledge": "2024-04", + "release_date": "2025-03-05", + "last_updated": "2025-03-05", "modalities": { "input": [ "text" @@ -55274,7 +57763,7 @@ "text" ] }, - "open_weights": true, + "open_weights": false, "cost": { "input": 0.8, "output": 2.4 @@ -55303,7 +57792,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.25, "output": 2.0, @@ -55311,7 +57800,7 @@ }, "limit": { "context": 256000, - "output": 16384 + "output": 8192 } }, { @@ -55333,7 +57822,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.25, "output": 2.0, @@ -55341,7 +57830,7 @@ }, "limit": { "context": 256000, - "output": 16384 + "output": 8192 } }, { @@ -55363,15 +57852,15 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.07, "output": 0.3, - "cache_read": 0.02 + "cache_read": 0.01 }, "limit": { "context": 256000, - "output": 16384 + "output": 8192 } }, { @@ -55393,7 +57882,7 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { "input": 0.25, "output": 2.0, @@ -55401,65 +57890,7 @@ }, "limit": { "context": 256000, - "output": 16384 - } - }, - { - "id": "llmgateway/seedream-4.0", - "name": "Seedream 4.0", - "family": "seed", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-09-16", - "last_updated": "2025-09-16", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 - } - }, - { - "id": "llmgateway/seedream-4.5", - "name": "Seedream 4.5", - "family": "seed", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-12-03", - "last_updated": "2025-12-03", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text", - "image" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 2000, - "output": 4096 + "output": 8192 } }, { @@ -55470,8 +57901,9 @@ "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-01-01", - "last_updated": "2025-01-01", + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", "modalities": { "input": [ "text" @@ -55486,23 +57918,25 @@ "output": 1.0 }, "limit": { - "context": 130000, - "output": 16384 + "context": 128000, + "output": 4096 } }, { "id": "llmgateway/sonar-pro", "name": "Sonar Pro", - "family": "sonar", - "attachment": false, + "family": "sonar-pro", + "attachment": true, "reasoning": false, "tool_call": false, "temperature": true, - "release_date": "2025-03-07", - "last_updated": "2025-03-07", + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -55515,22 +57949,24 @@ }, "limit": { "context": 200000, - "output": 16384 + "output": 8192 } }, { "id": "llmgateway/sonar-reasoning-pro", "name": "Sonar Reasoning Pro", - "family": "sonar", - "attachment": false, + "family": "sonar-reasoning", + "attachment": true, "reasoning": true, "tool_call": false, "temperature": true, - "release_date": "2025-03-07", - "last_updated": "2025-03-07", + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -55543,63 +57979,7 @@ }, "limit": { "context": 128000, - "output": 16384 - } - }, - { - "id": "llmgateway/veo-3.1-fast-generate-preview", - "name": "Veo 3.1 Fast", - "family": "gemini", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-03-14", - "last_updated": "2026-03-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 32768, - "output": 1 - } - }, - { - "id": "llmgateway/veo-3.1-generate-preview", - "name": "Veo 3.1", - "family": "gemini", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2026-03-14", - "last_updated": "2026-03-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 0.0, - "output": 0.0 - }, - "limit": { - "context": 32768, - "output": 1 + "output": 4096 } }, { @@ -58309,8 +60689,222 @@ { "id": "moonshotai-cn/kimi-k2.5", "name": "Kimi K2.5", + "family": "kimi-k2.5", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 3.0, + "cache_read": 0.1 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai-cn/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi-k2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai/kimi-k2-0711-preview", + "name": "Kimi K2 0711", + "family": "kimi", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2025-07-14", + "last_updated": "2025-07-14", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.5, + "cache_read": 0.15 + }, + "limit": { + "context": 131072, + "output": 16384 + } + }, + { + "id": "moonshotai/kimi-k2-0905-preview", + "name": "Kimi K2 0905", + "family": "kimi", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2025-09-05", + "last_updated": "2025-09-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.5, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai/kimi-k2-thinking", + "name": "Kimi K2 Thinking", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08", + "release_date": "2025-11-06", + "last_updated": "2025-11-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.6, + "output": 2.5, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai/kimi-k2-thinking-turbo", + "name": "Kimi K2 Thinking Turbo", + "family": "kimi-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-08", + "release_date": "2025-11-06", + "last_updated": "2025-11-06", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.15, + "output": 8.0, + "cache_read": 0.15 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", "family": "kimi", "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2025-09-05", + "last_updated": "2025-09-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 2.4, + "output": 10.0, + "cache_read": 0.6 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi-k2.5", + "attachment": false, "reasoning": true, "tool_call": true, "temperature": false, @@ -58339,166 +60933,16 @@ } }, { - "id": "moonshotai/kimi-k2-0711-preview", - "name": "Kimi K2 0711", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-07-14", - "last_updated": "2025-07-14", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.5, - "cache_read": 0.15 - }, - "limit": { - "context": 131072, - "output": 16384 - } - }, - { - "id": "moonshotai/kimi-k2-0905-preview", - "name": "Kimi K2 0905", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.5, - "cache_read": 0.15 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "moonshotai/kimi-k2-thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-thinking", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.6, - "output": 2.5, - "cache_read": 0.15 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "moonshotai/kimi-k2-thinking-turbo", - "name": "Kimi K2 Thinking Turbo", - "family": "kimi-thinking", - "attachment": false, + "id": "moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi-k2.6", + "attachment": true, "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2024-08", - "release_date": "2025-11-06", - "last_updated": "2025-11-06", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 1.15, - "output": 8.0, - "cache_read": 0.15 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "moonshotai/kimi-k2-turbo-preview", - "name": "Kimi K2 Turbo", - "family": "kimi", - "attachment": false, - "reasoning": false, - "tool_call": true, - "temperature": true, - "knowledge": "2024-10", - "release_date": "2025-09-05", - "last_updated": "2025-09-05", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 2.4, - "output": 10.0, - "cache_read": 0.6 - }, - "limit": { - "context": 262144, - "output": 262144 - } - }, - { - "id": "moonshotai/kimi-k2.5", - "name": "Kimi K2.5", - "family": "kimi", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": false, "knowledge": "2025-01", - "release_date": "2026-01", - "last_updated": "2026-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "modalities": { "input": [ "text", @@ -58511,9 +60955,9 @@ }, "open_weights": true, "cost": { - "input": 0.6, - "output": 3.0, - "cache_read": 0.1 + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 }, "limit": { "context": 262144, @@ -62534,6 +64978,35 @@ "output": 16384 } }, + { + "id": "nano-gpt/alibaba/qwen3.6-flash", + "name": "Qwen3.6 Flash", + "family": "qwen3.6", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.19, + "output": 1.16 + }, + "limit": { + "context": 991800, + "output": 65536 + } + }, { "id": "nano-gpt/allenai/molmo-2-8b", "name": "Molmo 2 8B", @@ -62958,6 +65431,7 @@ "attachment": true, "reasoning": false, "tool_call": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -62987,6 +65461,7 @@ "attachment": true, "reasoning": true, "tool_call": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -69023,6 +71498,62 @@ "output": 65536 } }, + { + "id": "nano-gpt/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi-k2.6", + "attachment": true, + "reasoning": false, + "tool_call": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.53, + "output": 2.73 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "nano-gpt/moonshotai/kimi-k2.6:thinking", + "name": "Kimi K2.6 Thinking", + "family": "kimi-thinking", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-16", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.53, + "output": 2.73 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, { "id": "nano-gpt/nex-agi/deepseek-v3.1-nex-n1", "name": "DeepSeek V3.1 Nex N1", @@ -70482,6 +73013,35 @@ "output": 8192 } }, + { + "id": "nano-gpt/qwen-3.6-plus", + "name": "Qwen 3.6 Plus", + "family": "qwen3.6", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.45, + "output": 2.7 + }, + "limit": { + "context": 991800, + "output": 65536 + } + }, { "id": "nano-gpt/qwen-image", "name": "Qwen Image", @@ -70612,6 +73172,64 @@ "output": 8192 } }, + { + "id": "nano-gpt/qwen/Qwen3.6-35B-A3B", + "name": "Qwen3.6 35B A3B", + "family": "qwen3.6", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-17", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.29, + "output": 1.74 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "nano-gpt/qwen/Qwen3.6-35B-A3B:thinking", + "name": "Qwen3.6 35B A3B Thinking", + "family": "qwen3.6", + "attachment": false, + "reasoning": true, + "tool_call": false, + "release_date": "2026-04-19", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.29, + "output": 1.74 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, { "id": "nano-gpt/qwen/qwen3.5-397b-a17b", "name": "Qwen3.5 397B A17B", @@ -70800,6 +73418,33 @@ "output": 32768 } }, + { + "id": "nano-gpt/qwen3.6-max-preview", + "name": "Qwen3.6 Max Preview", + "family": "qwen3.6", + "attachment": false, + "reasoning": false, + "tool_call": false, + "release_date": "2026-04-20", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.3, + "output": 7.8 + }, + "limit": { + "context": 245800, + "output": 65536 + } + }, { "id": "nano-gpt/qwq-32b", "name": "Qwen: QwQ 32B", @@ -74194,6 +76839,62 @@ "output": 8192 } }, + { + "id": "novita-ai/deepseek/deepseek-r1-distill-qwen-14b", + "name": "DeepSeek R1 Distill Qwen 14B", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2025-01-20", + "last_updated": "2025-01-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.15 + }, + "limit": { + "context": 32768, + "output": 16384 + } + }, + { + "id": "novita-ai/deepseek/deepseek-r1-distill-qwen-32b", + "name": "DeepSeek R1 Distill Qwen 32B", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": false, + "temperature": true, + "release_date": "2025-01-20", + "last_updated": "2025-01-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 0.3 + }, + "limit": { + "context": 64000, + "output": 32000 + } + }, { "id": "novita-ai/deepseek/deepseek-r1-turbo", "name": "DeepSeek R1 (Turbo)\t", @@ -74392,6 +77093,36 @@ "output": 65536 } }, + { + "id": "novita-ai/google/gemma-3-12b-it", + "name": "Gemma 3 12B", + "family": "gemma", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2025-03-13", + "last_updated": "2025-03-13", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.05, + "output": 0.1 + }, + "limit": { + "context": 131072, + "output": 8192 + } + }, { "id": "novita-ai/google/gemma-3-27b-it", "name": "Gemma 3 27B", @@ -74506,6 +77237,34 @@ "output": 3200 } }, + { + "id": "novita-ai/inclusionai/ling-2.6-1t", + "name": "Ling-2.6-1T", + "family": "ling", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262144, + "output": 32768 + } + }, { "id": "novita-ai/kwaipilot/kat-coder-pro", "name": "Kat Coder Pro", @@ -74618,6 +77377,34 @@ "output": 16384 } }, + { + "id": "novita-ai/meta-llama/llama-3.2-3b-instruct", + "name": "Llama 3.2 3B Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2024-09-18", + "last_updated": "2024-09-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.03, + "output": 0.05 + }, + "limit": { + "context": 32768, + "output": 32000 + } + }, { "id": "novita-ai/meta-llama/llama-3.3-70b-instruct", "name": "Llama 3.3 70B Instruct", @@ -75047,6 +77834,38 @@ "output": 262144 } }, + { + "id": "novita-ai/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "novita-ai/nousresearch/hermes-2-pro-llama-3-8b", "name": "Hermes 2 Pro Llama 3 8B", @@ -78433,6 +81252,34 @@ "output": 16384 } }, + { + "id": "nvidia/z-ai/glm-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-27", + "last_updated": "2026-03-27", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 131072, + "output": 131072 + } + }, { "id": "nvidia/z-ai/glm4.7", "name": "GLM-4.7", @@ -78735,7 +81582,7 @@ "cost": {}, "limit": { "context": 262144, - "output": 8192 + "output": 262144 } }, { @@ -78932,6 +81779,31 @@ "output": 262144 } }, + { + "id": "ollama-cloud/kimi-k2.6:cloud", + "name": "kimi-k2.6:cloud", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": {}, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "ollama-cloud/kimi-k2:1t", "name": "kimi-k2:1t", @@ -79400,36 +82272,6 @@ "output": 0 } }, - { - "id": "openai/codex-mini", - "name": "Codex Mini", - "family": "gpt-codex-mini", - "attachment": true, - "reasoning": true, - "tool_call": true, - "temperature": false, - "knowledge": "2024-04", - "release_date": "2025-05-16", - "last_updated": "2025-05-16", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": false, - "cost": { - "input": 1.5, - "output": 6.0, - "cache_read": 0.375 - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, { "id": "openai/gpt-3.5-turbo", "name": "GPT-3.5-turbo", @@ -80359,6 +83201,38 @@ "output": 128000 } }, + { + "id": "openai/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1050000, + "output": 130000 + } + }, { "id": "openai/gpt-image-1", "name": "gpt-image-1", @@ -80833,6 +83707,66 @@ "output": 1536 } }, + { + "id": "opencode-go/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "opencode-go/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48, + "cache_read": 0.145 + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, { "id": "opencode-go/glm-5", "name": "GLM-5", @@ -80896,7 +83830,7 @@ { "id": "opencode-go/kimi-k2.5", "name": "Kimi K2.5", - "family": "kimi", + "family": "kimi-k2.5", "attachment": true, "reasoning": true, "tool_call": true, @@ -80925,10 +83859,42 @@ "output": 65536 } }, + { + "id": "opencode-go/kimi-k2.6", + "name": "Kimi K2.6 (3x limits)", + "family": "kimi-k2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.32, + "output": 1.34, + "cache_read": 0.054 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "opencode-go/mimo-v2-omni", "name": "MiMo V2 Omni", - "family": "mimo-omni", + "family": "mimo-v2-omni", "attachment": true, "reasoning": true, "tool_call": true, @@ -80955,13 +83921,13 @@ }, "limit": { "context": 262144, - "output": 64000 + "output": 128000 } }, { "id": "opencode-go/mimo-v2-pro", "name": "MiMo V2 Pro", - "family": "mimo-pro", + "family": "mimo-v2-pro", "attachment": true, "reasoning": true, "tool_call": true, @@ -80985,7 +83951,70 @@ }, "limit": { "context": 1048576, - "output": 64000 + "output": 128000 + } + }, + { + "id": "opencode-go/mimo-v2.5", + "name": "MiMo V2.5", + "family": "mimo-v2.5", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text", + "image", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 2.0, + "cache_read": 0.08 + }, + "limit": { + "context": 262144, + "output": 128000 + } + }, + { + "id": "opencode-go/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "family": "mimo-v2.5-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.0, + "output": 3.0, + "cache_read": 0.2 + }, + "limit": { + "context": 1048576, + "output": 128000 } }, { @@ -81015,7 +84044,7 @@ }, "limit": { "context": 204800, - "output": 131072 + "output": 65536 } }, { @@ -81285,9 +84314,9 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08-31", + "knowledge": "2025-05-31", "release_date": "2026-02-05", - "last_updated": "2026-02-05", + "last_updated": "2026-03-13", "modalities": { "input": [ "text", @@ -81318,7 +84347,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -81417,7 +84446,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-07-31", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -82194,6 +85223,70 @@ "output": 128000 } }, + { + "id": "opencode/gpt-5.5", + "name": "GPT-5.5", + "family": "gpt", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5 + }, + "limit": { + "context": 1050000, + "output": 130000 + } + }, + { + "id": "opencode/gpt-5.5-pro", + "name": "GPT-5.5 Pro", + "family": "gpt-pro", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 30.0, + "output": 180.0, + "cache_read": 30.0 + }, + "limit": { + "context": 1050000, + "output": 128000 + } + }, { "id": "opencode/grok-code", "name": "Grok Code Fast 1", @@ -82224,6 +85317,36 @@ "output": 256000 } }, + { + "id": "opencode/hy3-preview-free", + "name": "Hy3 preview Free", + "family": "hy3-free", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-06", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "opencode/kimi-k2", "name": "Kimi K2", @@ -82348,6 +85471,67 @@ "output": 262144 } }, + { + "id": "opencode/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2024-10", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, + { + "id": "opencode/ling-2.6-flash-free", + "name": "Ling 2.6 Flash Free", + "family": "ling-flash-free", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2025-06", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0 + }, + "limit": { + "context": 262100, + "output": 32800 + } + }, { "id": "opencode/mimo-v2-flash-free", "name": "MiMo V2 Flash Free", @@ -82561,6 +85745,36 @@ "output": 131072 } }, + { + "id": "opencode/minimax-m2.7", + "name": "MiniMax M2.7", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 204800, + "output": 131072 + } + }, { "id": "opencode/nemotron-3-super-free", "name": "Nemotron 3 Super Free", @@ -82951,7 +86165,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05-30", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -82976,6 +86190,39 @@ "output": 128000 } }, + { + "id": "openrouter/anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 25.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, { "id": "openrouter/anthropic/claude-sonnet-4", "name": "Claude Sonnet 4", @@ -83050,6 +86297,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -83541,6 +86789,66 @@ "output": 65536 } }, + { + "id": "openrouter/deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek-flash", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "limit": { + "context": 1048576, + "output": 393216 + } + }, + { + "id": "openrouter/deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek-thinking", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.74, + "output": 3.48, + "cache_read": 0.145 + }, + "limit": { + "context": 1048576, + "output": 393216 + } + }, { "id": "openrouter/google/gemini-2.0-flash-001", "name": "Gemini 2.0 Flash", @@ -83738,7 +87046,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.31 + "cache_read": 0.125 }, "limit": { "context": 1048576, @@ -85192,6 +88500,36 @@ "output": 262144 } }, + { + "id": "openrouter/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262144, + "output": 262144 + } + }, { "id": "openrouter/nousresearch/hermes-3-llama-3.1-405b:free", "name": "Hermes 3 405B Instruct (free)", @@ -86126,9 +89464,9 @@ }, "open_weights": false, "cost": { - "input": 7.5e-7, - "output": 4.5e-6, - "cache_read": 7.5e-8 + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 }, "limit": { "context": 400000, @@ -86158,9 +89496,9 @@ }, "open_weights": false, "cost": { - "input": 2e-7, - "output": 1.25e-6, - "cache_read": 2e-8 + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 }, "limit": { "context": 400000, @@ -87827,33 +91165,6 @@ "output": 131072 } }, - { - "id": "ovhcloud/deepseek-r1-distill-llama-70b", - "name": "DeepSeek-R1-Distill-Llama-70B", - "attachment": false, - "reasoning": true, - "tool_call": true, - "temperature": true, - "release_date": "2025-01-30", - "last_updated": "2025-01-30", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.74, - "output": 0.74 - }, - "limit": { - "context": 131072, - "output": 131072 - } - }, { "id": "ovhcloud/gpt-oss-120b", "name": "gpt-oss-120b", @@ -88042,60 +91353,6 @@ "output": 131072 } }, - { - "id": "ovhcloud/mixtral-8x7b-instruct-v0.1", - "name": "Mixtral-8x7B-Instruct-v0.1", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-04-01", - "last_updated": "2025-04-01", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.7, - "output": 0.7 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, - { - "id": "ovhcloud/qwen2.5-coder-32b-instruct", - "name": "Qwen2.5-Coder-32B-Instruct", - "attachment": false, - "reasoning": false, - "tool_call": false, - "temperature": true, - "release_date": "2025-03-24", - "last_updated": "2025-03-24", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "open_weights": true, - "cost": { - "input": 0.96, - "output": 0.96 - }, - "limit": { - "context": 32768, - "output": 32768 - } - }, { "id": "ovhcloud/qwen2.5-vl-72b-instruct", "name": "Qwen2.5-VL-72B-Instruct", @@ -88250,7 +91507,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -88314,7 +91571,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -89028,6 +92285,37 @@ "output": 128000 } }, + { + "id": "poe/anthropic/claude-opus-4.7", + "name": "Claude-Opus-4.7", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "release_date": "2026-04-15", + "last_updated": "2026-04-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 4.3, + "output": 21.0, + "cache_read": 0.43, + "cache_write": 5.4 + }, + "limit": { + "context": 1048576, + "output": 128000 + } + }, { "id": "poe/anthropic/claude-sonnet-3.5", "name": "Claude-Sonnet-3.5", @@ -91658,6 +94946,35 @@ "output": 0 } }, + { + "id": "poe/openai/gpt-image-2", + "name": "GPT-Image-2", + "attachment": true, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0505, + "output": 32.3232, + "cache_read": 1.2626 + }, + "limit": { + "context": 0, + "output": 0 + } + }, { "id": "poe/openai/o1", "name": "o1", @@ -94980,6 +98297,373 @@ "output": 128000 } }, + { + "id": "regolo-ai/gpt-oss-120b", + "name": "GPT-OSS-120B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 4.2 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "regolo-ai/gpt-oss-20b", + "name": "GPT-OSS-20B", + "family": "gpt-oss", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.4, + "output": 1.8 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "regolo-ai/llama-3.1-8b-instruct", + "name": "Llama 3.1 8B Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-04-07", + "last_updated": "2025-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.05, + "output": 0.25 + }, + "limit": { + "context": 120000, + "output": 120000 + } + }, + { + "id": "regolo-ai/llama-3.3-70b-instruct", + "name": "Llama 3.3 70B Instruct", + "family": "llama", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2025-04-28", + "last_updated": "2025-04-28", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.6, + "output": 2.7 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + { + "id": "regolo-ai/minimax-m2.5", + "name": "MiniMax 2.5", + "family": "minimax", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-10", + "last_updated": "2026-03-10", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.8, + "output": 3.5 + }, + "limit": { + "context": 190000, + "output": 64000 + } + }, + { + "id": "regolo-ai/mistral-small-4-119b", + "name": "Mistral Small 4 119B", + "family": "mistral-small", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-15", + "last_updated": "2026-03-15", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.75, + "output": 3.0 + }, + "limit": { + "context": 256000, + "output": 16384 + } + }, + { + "id": "regolo-ai/mistral-small3.2", + "name": "Mistral Small 3.2", + "family": "mistral-small", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2025-01-31", + "last_updated": "2025-01-31", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 2.2 + }, + "limit": { + "context": 120000, + "output": 120000 + } + }, + { + "id": "regolo-ai/qwen-image", + "name": "Qwen-Image", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "image" + ] + }, + "open_weights": false, + "cost": { + "input": 0.5, + "output": 2.0 + }, + "limit": { + "context": 8192, + "output": 4096 + } + }, + { + "id": "regolo-ai/qwen3-coder-next", + "name": "Qwen3-Coder-Next", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.3, + "output": 1.2 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "regolo-ai/qwen3-embedding-8b", + "name": "Qwen3-Embedding-8B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-02-01", + "last_updated": "2026-02-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1, + "output": 0.1 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, + { + "id": "regolo-ai/qwen3-reranker-4b", + "name": "Qwen3-Reranker-4B", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": false, + "temperature": false, + "release_date": "2026-02-01", + "last_updated": "2026-02-01", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.12, + "output": 0.12 + }, + "limit": { + "context": 32768, + "output": 8192 + } + }, + { + "id": "regolo-ai/qwen3.5-122b", + "name": "Qwen3.5-122B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-01", + "last_updated": "2026-02-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.9, + "output": 3.6 + }, + "limit": { + "context": 262144, + "output": 16384 + } + }, + { + "id": "regolo-ai/qwen3.5-9b", + "name": "Qwen3.5-9B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-02-01", + "last_updated": "2026-02-01", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.15, + "output": 0.6 + }, + "limit": { + "context": 262144, + "output": 8192 + } + }, { "id": "requesty/anthropic/claude-3.7-sonnet", "name": "Claude Sonnet 3.7", @@ -95153,7 +98837,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05-30", + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-02-05", "modalities": { @@ -95252,6 +98936,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -95319,8 +99004,8 @@ "tool_call": true, "temperature": true, "knowledge": "2025-01", - "release_date": "2025-06-17", - "last_updated": "2025-06-17", + "release_date": "2025-03-20", + "last_updated": "2025-06-05", "modalities": { "input": [ "text", @@ -97752,10 +101437,40 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.55, - "output": 3.0 + "input": 0.45, + "output": 2.25 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "siliconflow-cn/Pro/moonshotai/Kimi-K2.6", + "name": "Pro/moonshotai/Kimi-K2.6", + "family": "kimi", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 }, "limit": { "context": 262000, @@ -98915,6 +102630,37 @@ "output": 65536 } }, + { + "id": "siliconflow-cn/Qwen/Qwen3.6-35B-A3B", + "name": "Qwen/Qwen3.6-35B-A3B", + "family": "qwen", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.23, + "output": 1.86 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "siliconflow-cn/THUDM/GLM-4-32B", "name": "THUDM/GLM-4-32B-0414", @@ -101249,10 +104995,40 @@ "text" ] }, - "open_weights": false, + "open_weights": true, "cost": { - "input": 0.55, - "output": 3.0 + "input": 0.45, + "output": 2.25 + }, + "limit": { + "context": 262000, + "output": 262000 + } + }, + { + "id": "siliconflow/moonshotai/Kimi-K2.6", + "name": "moonshotai/Kimi-K2.6", + "family": "kimi", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 }, "limit": { "context": 262000, @@ -103364,6 +107140,36 @@ "output": 16384 } }, + { + "id": "tencent-tokenhub/hy3-preview", + "name": "Hy3 preview", + "family": "Hy", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, { "id": "the-grid-ai/text-max", "name": "Text Max", @@ -103815,6 +107621,38 @@ "output": 262144 } }, + { + "id": "togetherai/moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "family": "kimi-k2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 1.2, + "output": 4.5, + "cache_read": 0.2 + }, + "limit": { + "context": 262144, + "output": 131000 + } + }, { "id": "togetherai/openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -104144,6 +107982,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-02-05", "last_updated": "2026-03-16", "modalities": { @@ -104175,6 +108014,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-05-31", "release_date": "2026-04-08", "last_updated": "2026-04-08", "modalities": { @@ -104268,6 +108108,7 @@ "reasoning": true, "tool_call": true, "temperature": true, + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-03-16", "modalities": { @@ -104321,6 +108162,62 @@ "output": 32768 } }, + { + "id": "venice/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 0.175, + "output": 0.35 + }, + "limit": { + "context": 1000000, + "output": 32768 + } + }, + { + "id": "venice/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "family": "deepseek", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.175, + "output": 4.35 + }, + "limit": { + "context": 1000000, + "output": 32768 + } + }, { "id": "venice/gemini-3-flash-preview", "name": "Gemini 3 Flash Preview", @@ -104388,6 +108285,35 @@ "output": 32768 } }, + { + "id": "venice/gemma-4-uncensored", + "name": "Gemma 4 Uncensored", + "family": "gemma", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-13", + "last_updated": "2026-04-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.1625, + "output": 0.5 + }, + "limit": { + "context": 256000, + "output": 8192 + } + }, { "id": "venice/google-gemma-3-27b-it", "name": "Google Gemma 3 27B Instruct", @@ -104487,7 +108413,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-12", - "last_updated": "2026-04-12", + "last_updated": "2026-04-19", "modalities": { "input": [ "text", @@ -104517,7 +108443,7 @@ "tool_call": false, "temperature": true, "release_date": "2026-03-12", - "last_updated": "2026-04-12", + "last_updated": "2026-04-19", "modalities": { "input": [ "text", @@ -104629,6 +108555,36 @@ "output": 65536 } }, + { + "id": "venice/kimi-k2-6", + "name": "Kimi K2.6", + "family": "kimi", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-20", + "last_updated": "2026-04-24", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.7448, + "output": 4.655, + "cache_read": 0.1463 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, { "id": "venice/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -105373,7 +109329,7 @@ "tool_call": true, "temperature": true, "release_date": "2026-03-05", - "last_updated": "2026-04-04", + "last_updated": "2026-04-19", "modalities": { "input": [ "text", @@ -105385,7 +109341,7 @@ }, "open_weights": true, "cost": { - "input": 0.05, + "input": 0.1, "output": 0.15 }, "limit": { @@ -105538,6 +109494,35 @@ "output": 8192 } }, + { + "id": "venice/venice-uncensored-1.2", + "name": "Venice Uncensored 1.2", + "family": "venice", + "attachment": true, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2026-04-01", + "last_updated": "2026-04-19", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.2, + "output": 0.9 + }, + "limit": { + "context": 128000, + "output": 8192 + } + }, { "id": "venice/venice-uncensored-role-play", "name": "Venice Role Play Uncensored", @@ -106877,7 +110862,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-05", + "knowledge": "2025-05-31", "release_date": "2026-02", "last_updated": "2026-02", "modalities": { @@ -107008,7 +110993,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-08", + "knowledge": "2025-08-31", "release_date": "2026-02-17", "last_updated": "2026-02-17", "modalities": { @@ -107661,7 +111646,7 @@ "cost": { "input": 0.3, "output": 2.5, - "cache_read": 0.075 + "cache_read": 0.03 }, "limit": { "context": 1048576, @@ -107858,7 +111843,7 @@ "cost": { "input": 1.25, "output": 10.0, - "cache_read": 0.31 + "cache_read": 0.125 }, "limit": { "context": 1048576, @@ -113171,6 +117156,70 @@ "output": 4096 } }, + { + "id": "wafer.ai/GLM-5.1", + "name": "GLM-5.1", + "family": "glm", + "attachment": false, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 202752, + "output": 131072 + } + }, + { + "id": "wafer.ai/Qwen3.5-397B-A17B", + "name": "Qwen3.5 397B A17B", + "family": "qwen", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": true, + "knowledge": "2025-04", + "release_date": "2026-02-16", + "last_updated": "2026-02-16", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.0, + "output": 0.0, + "cache_read": 0.0, + "cache_write": 0.0 + }, + "limit": { + "context": 262144, + "output": 65536 + } + }, { "id": "wandb/MiniMaxAI/MiniMax-M2.5", "name": "MiniMax M2.5", @@ -115540,7 +119589,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-01-01", + "knowledge": "2025-05-31", "release_date": "2026-02-06", "last_updated": "2026-02-06", "modalities": { @@ -115571,7 +119620,7 @@ "reasoning": true, "tool_call": true, "temperature": false, - "knowledge": "2026-01", + "knowledge": "2026-01-31", "release_date": "2026-04-16", "last_updated": "2026-04-16", "modalities": { @@ -115667,7 +119716,7 @@ "reasoning": true, "tool_call": true, "temperature": true, - "knowledge": "2025-01-01", + "knowledge": "2025-08-31", "release_date": "2026-02-18", "last_updated": "2026-02-18", "modalities": { @@ -116383,6 +120432,37 @@ "output": 64000 } }, + { + "id": "zenmux/moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2025-01-01", + "release_date": "2026-04-20", + "last_updated": "2026-04-20", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "open_weights": true, + "cost": { + "input": 0.95, + "output": 4.0, + "cache_read": 0.16 + }, + "limit": { + "context": 262140, + "output": 262140 + } + }, { "id": "zenmux/openai/gpt-5", "name": "GPT-5", diff --git a/crates/goose/src/providers/canonical/data/provider_metadata.json b/crates/goose/src/providers/canonical/data/provider_metadata.json index 72dd982cefff..f58e78de85a5 100644 --- a/crates/goose/src/providers/canonical/data/provider_metadata.json +++ b/crates/goose/src/providers/canonical/data/provider_metadata.json @@ -8,7 +8,7 @@ "env": [ "302AI_API_KEY" ], - "model_count": 64 + "model_count": 95 }, { "id": "alibaba", @@ -41,7 +41,7 @@ "env": [ "NANO_GPT_API_KEY" ], - "model_count": 518 + "model_count": 525 }, { "id": "abacus", @@ -74,7 +74,7 @@ "env": [ "SILICONFLOW_CN_API_KEY" ], - "model_count": 79 + "model_count": 81 }, { "id": "submodel", @@ -107,7 +107,7 @@ "env": [ "DEEPSEEK_API_KEY" ], - "model_count": 2 + "model_count": 4 }, { "id": "meta-llama", @@ -129,7 +129,7 @@ "env": [ "FIREWORKS_API_KEY" ], - "model_count": 17 + "model_count": 18 }, { "id": "kimi-for-coding", @@ -140,7 +140,7 @@ "env": [ "KIMI_API_KEY" ], - "model_count": 2 + "model_count": 3 }, { "id": "moark", @@ -162,7 +162,7 @@ "env": [ "OPENCODE_API_KEY" ], - "model_count": 9 + "model_count": 14 }, { "id": "io-net", @@ -184,7 +184,7 @@ "env": [ "DASHSCOPE_API_KEY" ], - "model_count": 76 + "model_count": 77 }, { "id": "minimax-cn-coding-plan", @@ -239,7 +239,7 @@ "env": [ "HF_TOKEN" ], - "model_count": 22 + "model_count": 23 }, { "id": "zenmux", @@ -250,7 +250,7 @@ "env": [ "ZENMUX_API_KEY" ], - "model_count": 88 + "model_count": 89 }, { "id": "upstage", @@ -272,7 +272,7 @@ "env": [ "NOVITA_API_KEY" ], - "model_count": 90 + "model_count": 96 }, { "id": "xiaomi-token-plan-cn", @@ -305,7 +305,7 @@ "env": [ "CHUTES_API_KEY" ], - "model_count": 69 + "model_count": 70 }, { "id": "dinference", @@ -349,7 +349,7 @@ "env": [ "KILO_API_KEY" ], - "model_count": 334 + "model_count": 335 }, { "id": "morph", @@ -371,7 +371,7 @@ "env": [ "GITHUB_TOKEN" ], - "model_count": 25 + "model_count": 26 }, { "id": "mixlayer", @@ -415,7 +415,7 @@ "env": [ "OPENCODE_API_KEY" ], - "model_count": 52 + "model_count": 58 }, { "id": "stepfun", @@ -448,7 +448,7 @@ "env": [ "POE_API_KEY" ], - "model_count": 128 + "model_count": 130 }, { "id": "helicone", @@ -459,7 +459,7 @@ "env": [ "HELICONE_API_KEY" ], - "model_count": 91 + "model_count": 90 }, { "id": "ollama-cloud", @@ -470,7 +470,7 @@ "env": [ "OLLAMA_API_KEY" ], - "model_count": 36 + "model_count": 37 }, { "id": "zai-coding-plan", @@ -503,7 +503,7 @@ "env": [ "BASETEN_API_KEY" ], - "model_count": 12 + "model_count": 13 }, { "id": "zhipuai-coding-plan", @@ -536,7 +536,7 @@ "env": [ "FIRMWARE_API_KEY" ], - "model_count": 24 + "model_count": 25 }, { "id": "lmstudio", @@ -569,7 +569,18 @@ "env": [ "MOONSHOT_API_KEY" ], - "model_count": 6 + "model_count": 7 + }, + { + "id": "wafer.ai", + "display_name": "Wafer", + "npm": "@ai-sdk/openai-compatible", + "api": "https://pass.wafer.ai/v1", + "doc": "https://docs.wafer.ai/wafer-pass", + "env": [ + "WAFER_API_KEY" + ], + "model_count": 2 }, { "id": "cloudferro-sherlock", @@ -635,7 +646,7 @@ "env": [ "NVIDIA_API_KEY" ], - "model_count": 76 + "model_count": 77 }, { "id": "inference", @@ -670,6 +681,17 @@ ], "model_count": 38 }, + { + "id": "digitalocean", + "display_name": "DigitalOcean", + "npm": "@ai-sdk/openai-compatible", + "api": "https://inference.do-ai.run/v1", + "doc": "https://docs.digitalocean.com/products/gradient-ai-platform/details/models/", + "env": [ + "DIGITALOCEAN_ACCESS_TOKEN" + ], + "model_count": 46 + }, { "id": "vultr", "display_name": "Vultr", @@ -701,7 +723,7 @@ "env": [ "OVHCLOUD_API_KEY" ], - "model_count": 13 + "model_count": 10 }, { "id": "friendli", @@ -723,7 +745,7 @@ "env": [ "CORTECS_API_KEY" ], - "model_count": 32 + "model_count": 34 }, { "id": "siliconflow", @@ -734,7 +756,7 @@ "env": [ "SILICONFLOW_API_KEY" ], - "model_count": 73 + "model_count": 74 }, { "id": "minimax", @@ -756,7 +778,7 @@ "env": [ "LLMGATEWAY_API_KEY" ], - "model_count": 203 + "model_count": 182 }, { "id": "cloudflare-workers-ai", @@ -768,7 +790,7 @@ "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY" ], - "model_count": 7 + "model_count": 8 }, { "id": "fastrouter", @@ -835,7 +857,7 @@ "env": [ "MOONSHOT_API_KEY" ], - "model_count": 6 + "model_count": 7 }, { "id": "berget", @@ -846,7 +868,7 @@ "env": [ "BERGET_API_KEY" ], - "model_count": 8 + "model_count": 5 }, { "id": "github-models", @@ -870,6 +892,17 @@ ], "model_count": 9 }, + { + "id": "tencent-tokenhub", + "display_name": "Tencent TokenHub", + "npm": "@ai-sdk/openai-compatible", + "api": "https://tokenhub.tencentmaas.com/v1", + "doc": "https://cloud.tencent.com/document/product/1823/130050", + "env": [ + "TENCENT_TOKENHUB_API_KEY" + ], + "model_count": 1 + }, { "id": "modelscope", "display_name": "ModelScope", @@ -925,6 +958,17 @@ ], "model_count": 6 }, + { + "id": "regolo-ai", + "display_name": "Regolo AI", + "npm": "@ai-sdk/openai-compatible", + "api": "https://api.regolo.ai/v1", + "doc": "https://docs.regolo.ai/", + "env": [ + "REGOLO_API_KEY" + ], + "model_count": 13 + }, { "id": "xiaomi-token-plan-ams", "display_name": "Xiaomi Token Plan (Europe)", diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 6f4ab1ca3f9a..03f20788b2ac 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -5,7 +5,7 @@ use crate::providers::api_client::AuthProvider; use crate::providers::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use crate::providers::errors::ProviderError; use crate::providers::formats::openai_responses::responses_api_to_streaming_message; -use crate::providers::openai_compatible::handle_status_openai_compat; +use crate::providers::openai_compatible::handle_status; use crate::providers::retry::ProviderRetry; use crate::session_context::SESSION_ID_HEADER; use anyhow::{anyhow, Result}; @@ -62,22 +62,6 @@ pub const CHATGPT_CODEX_KNOWN_MODELS: &[ChatGptCodexModelAttrs] = &[ name: "gpt-5.3-codex", reasoning_levels: &["low", "medium", "high", "xhigh"], }, - ChatGptCodexModelAttrs { - name: "gpt-5.2-codex", - reasoning_levels: &["low", "medium", "high", "xhigh"], - }, - ChatGptCodexModelAttrs { - name: "gpt-5.1-codex", - reasoning_levels: &["low", "medium", "high", "xhigh"], - }, - ChatGptCodexModelAttrs { - name: "gpt-5.1-codex-mini", - reasoning_levels: &["medium", "high"], - }, - ChatGptCodexModelAttrs { - name: "gpt-5.1-codex-max", - reasoning_levels: &["low", "medium", "high", "xhigh"], - }, ]; const CHATGPT_CODEX_DOC_URL: &str = "https://openai.com/chatgpt"; @@ -806,6 +790,10 @@ impl ChatGptCodexAuthProvider { } } + fn clear_cached_tokens(&self) { + self.cache.clear(); + } + async fn get_valid_token(&self) -> Result { if let Some(mut token_data) = self.cache.load() { if token_data.expires_at > Utc::now() + chrono::Duration::seconds(60) { @@ -865,6 +853,11 @@ pub struct ChatGptCodexProvider { } impl ChatGptCodexProvider { + pub async fn cleanup() -> Result<()> { + TokenCache::new().clear(); + Ok(()) + } + pub async fn from_env(model: ModelConfig) -> Result { let auth_provider = Arc::new(ChatGptCodexAuthProvider::new( ChatGptCodexAuthState::instance(), @@ -919,7 +912,7 @@ impl ChatGptCodexProvider { .await .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; - handle_status_openai_compat(response).await + handle_status(response).await } } @@ -997,10 +990,25 @@ impl Provider for ChatGptCodexProvider { } async fn configure_oauth(&self) -> Result<(), ProviderError> { - self.auth_provider - .get_valid_token() + let previous_token = self.auth_provider.cache.load(); + self.auth_provider.clear_cached_tokens(); + + let result = perform_oauth_flow(self.auth_provider.state.as_ref()) .await - .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?; + .and_then(|token_data| self.auth_provider.cache.save(&token_data)); + + if let Err(e) = result { + if let Some(previous_token) = previous_token.as_ref() { + if self.auth_provider.cache.load().is_none() { + let _ = self.auth_provider.cache.save(previous_token); + } + } + return Err(ProviderError::Authentication(format!( + "OAuth flow failed: {}", + e + ))); + } + Ok(()) } diff --git a/crates/goose/src/providers/claude_acp.rs b/crates/goose/src/providers/claude_acp.rs index 9fb25be28f11..282b7e0dd935 100644 --- a/crates/goose/src/providers/claude_acp.rs +++ b/crates/goose/src/providers/claude_acp.rs @@ -9,10 +9,12 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp"; -const CLAUDE_ACP_DOC_URL: &str = "https://github.com/zed-industries/claude-agent-acp"; +const CLAUDE_ACP_DOC_URL: &str = "https://github.com/agentclientprotocol/claude-agent-acp"; const CLAUDE_ACP_BINARY: &str = "claude-agent-acp"; pub struct ClaudeAcpProvider; @@ -31,7 +33,7 @@ impl ProviderDef for ClaudeAcpProvider { vec![], ) .with_setup_steps(vec![ - "Install the ACP adapter: `npm install -g @zed-industries/claude-agent-acp`", + "Install the ACP adapter: `npm install -g @agentclientprotocol/claude-agent-acp`", "Ensure your Claude CLI is authenticated (run `claude` to verify)", "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: claude-acp\n GOOSE_MODEL: current", "Restart goose for changes to take effect", @@ -78,4 +80,16 @@ impl ProviderDef for ClaudeAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(CLAUDE_ACP_PROVIDER_NAME, CLAUDE_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(CLAUDE_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/codex_acp.rs b/crates/goose/src/providers/codex_acp.rs index f28bd02917cf..21f0a328cb05 100644 --- a/crates/goose/src/providers/codex_acp.rs +++ b/crates/goose/src/providers/codex_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp"; const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp"; @@ -98,6 +100,18 @@ impl ProviderDef for CodexAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(CODEX_ACP_PROVIDER_NAME, CODEX_ACP_PROVIDER_NAME) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(CODEX_ACP_PROVIDER_NAME) + } } // Codex sandbox scope determines what needs approval: operations within the diff --git a/crates/goose/src/providers/copilot_acp.rs b/crates/goose/src/providers/copilot_acp.rs index 9810547ff384..35bed3dec04a 100644 --- a/crates/goose/src/providers/copilot_acp.rs +++ b/crates/goose/src/providers/copilot_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const COPILOT_ACP_PROVIDER_NAME: &str = "copilot-acp"; const COPILOT_ACP_DOC_URL: &str = "https://github.com/github/copilot-cli"; @@ -84,4 +86,16 @@ impl ProviderDef for CopilotAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(COPILOT_ACP_PROVIDER_NAME, COPILOT_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(COPILOT_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index dcbe3d112f75..5fc7b09ac544 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -22,7 +22,7 @@ use super::formats::openai_responses::{ }; use super::oauth; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + handle_response_openai_compat, handle_status, map_http_error_to_provider_error, stream_openai_compat, }; use super::retry::ProviderRetry; @@ -269,17 +269,19 @@ impl DatabricksProvider { } fn is_responses_model(model_name: &str) -> bool { - let normalized = model_name.to_ascii_lowercase(); - normalized.contains("codex") + super::utils::is_openai_responses_model(model_name) } fn get_endpoint_path(&self, model_name: &str, is_embedding: bool) -> String { if is_embedding { "serving-endpoints/text-embedding-3-small/invocations".to_string() - } else if Self::is_responses_model(model_name) { - "serving-endpoints/responses".to_string() } else { - format!("serving-endpoints/{}/invocations", model_name) + let (clean_name, _) = super::utils::extract_reasoning_effort(model_name); + if Self::is_responses_model(&clean_name) { + "serving-endpoints/responses".to_string() + } else { + format!("serving-endpoints/{}/invocations", clean_name) + } } } @@ -340,6 +342,10 @@ impl ProviderDef for DatabricksProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } } #[async_trait] @@ -390,7 +396,7 @@ impl Provider for DatabricksProvider { .api_client .response_post(Some(session_id), &path, &payload_clone) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -590,3 +596,48 @@ impl EmbeddingCapable for DatabricksProvider { Ok(embeddings) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_provider() -> DatabricksProvider { + DatabricksProvider { + api_client: super::super::api_client::ApiClient::new( + "https://example.com".to_string(), + super::super::api_client::AuthMethod::NoAuth, + ) + .unwrap(), + auth: DatabricksAuth::Token("fake".into()), + model: ModelConfig::new_or_fail("databricks-gpt-5.4"), + image_format: ImageFormat::OpenAi, + retry_config: RetryConfig::default(), + fast_retry_config: RetryConfig::new(0, 0, 1.0, 0), + name: "databricks".into(), + token_cache: std::sync::Arc::new(std::sync::Mutex::new(None)), + instance_id: None, + } + } + + #[test] + fn responses_models_route_to_responses_endpoint() { + let provider = test_provider(); + + for (model_name, expected_path) in [ + ("gpt-5.4", "serving-endpoints/responses"), + ("databricks-gpt-5.4-high", "serving-endpoints/responses"), + ("databricks-gpt-5-4-xhigh", "serving-endpoints/responses"), + ("o3-mini", "serving-endpoints/responses"), + ( + "databricks-claude-sonnet-4", + "serving-endpoints/databricks-claude-sonnet-4/invocations", + ), + ] { + assert_eq!( + provider.get_endpoint_path(model_name, false), + expected_path, + "unexpected endpoint for {model_name}" + ); + } + } +} diff --git a/crates/goose/src/providers/declarative/nvidia.json b/crates/goose/src/providers/declarative/nvidia.json new file mode 100644 index 000000000000..787422835b26 --- /dev/null +++ b/crates/goose/src/providers/declarative/nvidia.json @@ -0,0 +1,24 @@ +{ + "name": "nvidia", + "engine": "openai", + "display_name": "NVIDIA", + "description": "Hosted NVIDIA NIM models through the OpenAI-compatible API.", + "api_key_env": "NVIDIA_API_KEY", + "base_url": "https://integrate.api.nvidia.com/v1", + "catalog_provider_id": "nvidia", + "dynamic_models": true, + "models": [ + { + "name": "z-ai/glm-4.7", + "context_limit": 131072 + } + ], + "supports_streaming": true, + "model_doc_link": "https://build.nvidia.com/models", + "setup_steps": [ + "Sign in to https://build.nvidia.com", + "Choose a Free Endpoint model from the model catalog", + "Create an API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index fb712cc34336..584bbdf8234c 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -2,8 +2,9 @@ use crate::conversation::message::{Message, MessageContent}; use crate::model::ModelConfig; use crate::providers::formats::anthropic::{thinking_effort, thinking_type, ThinkingType}; use crate::providers::utils::{ - convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json, - sanitize_function_name, ImageFormat, + convert_image, detect_image_path, extract_reasoning_effort, is_openai_responses_model, + is_valid_function_name, load_image_file, safely_parse_json, sanitize_function_name, + ImageFormat, }; use anyhow::{anyhow, Error}; use rmcp::model::{ @@ -581,24 +582,8 @@ pub fn create_request( )); } - let is_openai_reasoning_model = model_config.is_openai_reasoning_model(); - let (model_name, reasoning_effort) = if is_openai_reasoning_model { - let parts: Vec<&str> = model_config.model_name.split('-').collect(); - let last_part = parts.last().unwrap(); - - match *last_part { - "low" | "medium" | "high" => { - let base_name = parts[..parts.len() - 1].join("-"); - (base_name, Some(last_part.to_string())) - } - _ => ( - model_config.model_name.to_string(), - Some("medium".to_string()), - ), - } - } else { - (model_config.model_name.to_string(), None) - }; + let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name); + let is_openai_reasoning_model = is_openai_responses_model(&model_name); let system_message = DatabricksMessage { role: "system".to_string(), @@ -1073,6 +1058,63 @@ mod tests { Ok(()) } + #[test] + fn test_create_request_reasoning_effort_xhigh() -> anyhow::Result<()> { + let model_config = ModelConfig { + model_name: "o3-xhigh".to_string(), + context_limit: Some(4096), + temperature: None, + max_tokens: Some(1024), + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; + assert_eq!(request["model"], "o3"); + assert_eq!(request["reasoning_effort"], "xhigh"); + Ok(()) + } + + #[test] + fn test_create_request_reasoning_effort_none() -> anyhow::Result<()> { + let model_config = ModelConfig { + model_name: "o3-none".to_string(), + context_limit: Some(4096), + temperature: None, + max_tokens: Some(1024), + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; + assert_eq!(request["model"], "o3"); + assert_eq!(request["reasoning_effort"], "none"); + Ok(()) + } + + #[test] + fn test_create_request_reasoning_effort_for_prefixed_gpt5_model() -> anyhow::Result<()> { + let model_config = ModelConfig { + model_name: "databricks-gpt-5.4-high".to_string(), + context_limit: Some(4096), + temperature: None, + max_tokens: Some(1024), + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; + assert_eq!(request["model"], "databricks-gpt-5.4"); + assert_eq!(request["reasoning_effort"], "high"); + Ok(()) + } + #[test] fn test_create_request_adaptive_thinking_for_46_models() -> anyhow::Result<()> { let _guard = env_lock::lock_env([ @@ -1100,12 +1142,16 @@ mod tests { fn test_create_request_enabled_thinking_with_budget() -> anyhow::Result<()> { let _guard = env_lock::lock_env([ ("CLAUDE_THINKING_TYPE", None::<&str>), - ("CLAUDE_THINKING_ENABLED", Some("1")), + ("CLAUDE_THINKING_ENABLED", None::<&str>), ("CLAUDE_THINKING_BUDGET", Some("10000")), ]); let mut model_config = ModelConfig::new_or_fail("databricks-claude-3-7-sonnet"); model_config.max_tokens = Some(4096); + model_config = model_config.with_request_params(Some(std::collections::HashMap::from([( + "thinking_type".to_string(), + json!("enabled"), + )]))); let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index e896ecb16432..7633b8579022 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -4,8 +4,9 @@ use crate::model::ModelConfig; use crate::providers::base::{ProviderUsage, Usage}; use crate::providers::errors::ProviderError; use crate::providers::utils::{ - convert_image, detect_image_path, extract_reasoning_effort, is_valid_function_name, - load_image_file, safely_parse_json, sanitize_function_name, ImageFormat, + convert_image, detect_image_path, extract_reasoning_effort, is_openai_responses_model, + is_valid_function_name, load_image_file, safely_parse_json, sanitize_function_name, + ImageFormat, }; use anyhow::{anyhow, Error}; use async_stream::try_stream; @@ -64,19 +65,31 @@ struct ContentPart { thought_signature: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] struct Delta { #[serde(default)] content: Option, role: Option, tool_calls: Option>, reasoning_details: Option>, - #[serde(alias = "reasoning")] + reasoning: Option, reasoning_content: Option, } +impl Delta { + /// Prefer `reasoning_content` (DeepSeek/OpenRouter) over `reasoning` + /// (vLLM); some servers (gpt-oss via vLLM) emit both. Skip empty values. + fn reasoning_text(&self) -> Option<&str> { + self.reasoning_content + .as_deref() + .filter(|s| !s.is_empty()) + .or_else(|| self.reasoning.as_deref().filter(|s| !s.is_empty())) + } +} + #[derive(Serialize, Deserialize, Debug)] struct StreamingChoice { + #[serde(default)] delta: Delta, index: Option, finish_reason: Option, @@ -768,7 +781,7 @@ where if let Some(details) = &chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } - if let Some(rc) = &chunk.choices[0].delta.reasoning_content { + if let Some(rc) = chunk.choices[0].delta.reasoning_text() { accumulated_reasoning_content.push_str(rc); } } @@ -810,7 +823,7 @@ where if let Some(details) = &tool_chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } - if let Some(rc) = &tool_chunk.choices[0].delta.reasoning_content { + if let Some(rc) = tool_chunk.choices[0].delta.reasoning_text() { accumulated_reasoning_content.push_str(rc); } if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls { @@ -918,14 +931,12 @@ where Some(msg), usage, ) - } else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_content.is_some() { + } else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_text().is_some() { let mut content = Vec::new(); - if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content { - if !reasoning.is_empty() { - let signature = last_signature.as_deref().unwrap_or(""); - content.push(MessageContent::thinking(reasoning, signature)); - } + if let Some(reasoning) = chunk.choices[0].delta.reasoning_text() { + let signature = last_signature.as_deref().unwrap_or(""); + content.push(MessageContent::thinking(reasoning, signature)); } let (text_content, thought_signature) = extract_content_and_signature(chunk.choices[0].delta.content.as_ref()); @@ -984,7 +995,7 @@ pub fn create_request( } let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name); - let is_reasoning_model = reasoning_effort.is_some(); + let is_reasoning_model = is_openai_responses_model(&model_name); let system_message = json!({ "role": if is_reasoning_model { "developer" } else { "system" }, @@ -1716,7 +1727,8 @@ mod tests { #[test] fn test_create_request_o1_default() -> anyhow::Result<()> { - // Test default medium reasoning effort for O1 model + // Without an explicit effort suffix the API picks its own default; + // we should omit reasoning_effort entirely but still use "developer" role. let model_config = ModelConfig { model_name: "o1".to_string(), context_limit: Some(4096), @@ -1745,13 +1757,16 @@ mod tests { "content": "system" } ], - "reasoning_effort": "medium", "max_completion_tokens": 1024 }); for (key, value) in expected.as_object().unwrap() { assert_eq!(obj.get(key).unwrap(), value); } + assert!( + obj.get("reasoning_effort").is_none(), + "reasoning_effort should be omitted when no explicit suffix is provided" + ); Ok(()) } @@ -1998,6 +2013,23 @@ data: [DONE] Ok(()) } + #[tokio::test] + async fn test_azure_annotation_chunk_without_delta_does_not_fail() -> anyhow::Result<()> { + let response_lines = r#" +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1234567890,"model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}],"usage":null} +data: {"choices":[{"content_filter_offsets":{"check_offset":5,"start_offset":5,"end_offset":5},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":null,"index":0}],"created":0,"id":"","model":"","object":""} +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1234567891,"model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":1,"total_tokens":11}} +data: [DONE] +"#; + + let result = run_streaming_test(response_lines).await?; + + assert!(result.has_text_content, "Expected text content in response"); + assert_usage_yielded_once(&result, 10, 1, 11); + + Ok(()) + } + #[test] fn test_response_to_message_with_nested_extra_content() -> anyhow::Result<()> { let response = json!({ @@ -2338,4 +2370,69 @@ data: [DONE]"#; assert_eq!(result.output_tokens, Some(128)); assert_eq!(result.total_tokens, Some(170)); } + + // vLLM serving gpt-oss emits both `reasoning` and `reasoning_content` + // in the same payload; the non-streaming path handles it fine today. + #[test] + fn test_response_to_message_with_both_reasoning_fields() -> anyhow::Result<()> { + let response = json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": "answer", + "reasoning": "thinking...", + "reasoning_content": "thinking..." + } + }] + }); + + let message = response_to_message(&response)?; + assert_eq!(message.content.len(), 2); + if let MessageContent::Thinking(t) = &message.content[0] { + assert_eq!(t.thinking, "thinking..."); + } else { + panic!("Expected Thinking content, got {:?}", message.content[0]); + } + Ok(()) + } + + #[tokio::test] + async fn test_streaming_chunk_with_only_reasoning_content() -> anyhow::Result<()> { + let response_lines = "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"hi\"},\"finish_reason\":null}]}\ndata: [DONE]"; + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + while let Some(result) = messages.next().await { + result?; + } + Ok(()) + } + + // Streaming counterpart: both fields in one delta must parse and yield + // thinking content, not fail with "duplicate field `reasoning_content`". + #[tokio::test] + async fn test_streaming_chunk_with_both_reasoning_fields() -> anyhow::Result<()> { + let response_lines = "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning\":\"thinking...\",\"reasoning_content\":\"thinking...\"},\"finish_reason\":null}]}\ndata: [DONE]"; + let lines: Vec = response_lines.lines().map(|s| s.to_string()).collect(); + let response_stream = tokio_stream::iter(lines.into_iter().map(Ok)); + let mut messages = std::pin::pin!(response_to_streaming_message(response_stream)); + + let mut saw_thinking = false; + while let Some(result) = messages.next().await { + let (message, _usage) = result?; + if let Some(msg) = message { + for c in &msg.content { + if let MessageContent::Thinking(t) = c { + assert_eq!(t.thinking, "thinking..."); + saw_thinking = true; + } + } + } + } + assert!( + saw_thinking, + "expected thinking content from merged reasoning fields" + ); + Ok(()) + } } diff --git a/crates/goose/src/providers/formats/openai_responses.rs b/crates/goose/src/providers/formats/openai_responses.rs index 68e0f785f549..1023805953c2 100644 --- a/crates/goose/src/providers/formats/openai_responses.rs +++ b/crates/goose/src/providers/formats/openai_responses.rs @@ -2,7 +2,7 @@ use crate::conversation::message::{Message, MessageContent}; use crate::mcp_utils::extract_text_from_resource; use crate::model::ModelConfig; use crate::providers::base::{ProviderUsage, Usage}; -use crate::providers::utils::extract_reasoning_effort; +use crate::providers::utils::{extract_reasoning_effort, is_openai_responses_model}; use anyhow::{anyhow, Error}; use async_stream::try_stream; use chrono; @@ -468,7 +468,10 @@ pub fn create_responses_request( add_message_items(&mut input_items, messages); let (model_name, reasoning_effort) = extract_reasoning_effort(&model_config.model_name); - let is_reasoning_model = reasoning_effort.is_some(); + // All models routed here are responses-capable; temperature is rejected + // by the API for reasoning models regardless of whether an explicit + // effort suffix was provided. + let is_reasoning_model = is_openai_responses_model(&model_name); let mut payload = json!({ "model": model_name, @@ -495,6 +498,7 @@ pub fn create_responses_request( "name": tool.name, "description": tool.description, "parameters": tool.input_schema, + "strict": false, }) }) .collect(); @@ -1083,4 +1087,103 @@ mod tests { assert_eq!(info.effort.as_deref(), Some("high")); assert_eq!(info.summary.as_deref(), Some("Thought deeply")); } + + #[test] + fn test_responses_tools_include_strict_false() { + let model_config = ModelConfig { + model_name: "gpt-5.4".to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + + let tool = Tool::new( + "shell", + "Execute a shell command", + object!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to run" + } + }, + "required": ["command"] + }), + ); + + let result = + create_responses_request(&model_config, "You are helpful.", &[], &[tool]).unwrap(); + let tools = result["tools"] + .as_array() + .expect("tools should be an array"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["strict"], json!(false), + "Responses API defaults strict to true, but MCP tool schemas are not strict-compatible; must explicitly set strict: false"); + } + + #[test] + fn test_responses_request_with_explicit_effort_suffix() { + for (model_name, expected_model, expected_effort) in [ + ("gpt-5.4-xhigh", "gpt-5.4", "xhigh"), + ("databricks-gpt-5.4-high", "databricks-gpt-5.4", "high"), + ("databricks-o3-none", "databricks-o3", "none"), + ] { + let model_config = ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + + let result = + create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!( + result["model"], expected_model, + "unexpected model for {model_name}" + ); + assert_eq!( + result["reasoning"]["effort"], expected_effort, + "unexpected effort for {model_name}" + ); + assert_eq!(result["reasoning"]["summary"], "auto"); + } + } + + #[test] + fn test_responses_request_without_effort_suffix_omits_reasoning() { + for model_name in ["gpt-5.4", "o3", "gpt-5-nano"] { + let model_config = ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + fast_model_config: None, + request_params: None, + reasoning: None, + }; + + let result = + create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!(result["model"], model_name, "model should be unchanged"); + assert!( + result.get("reasoning").is_none(), + "reasoning should be omitted for {model_name} without explicit effort suffix" + ); + } + } } diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 2ce16cf2cbb6..c65de1637ec6 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -1,6 +1,7 @@ use crate::config::paths::Paths; use crate::providers::api_client::{ApiClient, AuthMethod}; -use crate::providers::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use crate::providers::oauth_device_flow::{run_device_flow, DeviceFlowConfig, RequestEncoding}; +use crate::providers::openai_compatible::{handle_status, stream_openai_compat}; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use axum::http; @@ -92,13 +93,6 @@ impl GithubCopilotUrls { } } -#[derive(Debug, Deserialize)] -struct DeviceCodeInfo { - device_code: String, - user_code: String, - verification_uri: String, -} - #[derive(Debug, Serialize, Deserialize, Clone)] struct CopilotTokenEndpoints { api: String, @@ -344,105 +338,16 @@ impl GithubCopilotProvider { } async fn login(&self) -> Result { - let device_code_info = self.get_device_code().await?; - - if let Ok(mut clipboard) = arboard::Clipboard::new() { - if let Err(e) = clipboard.set_text(&device_code_info.user_code) { - tracing::warn!("Failed to copy verification code to clipboard: {}", e); - } - } - - if let Err(e) = webbrowser::open(&device_code_info.verification_uri) { - tracing::warn!("Failed to open browser: {}", e); - } - - println!( - "Please visit {} and enter code {}", - device_code_info.verification_uri, device_code_info.user_code - ); - - self.poll_for_access_token(&device_code_info.device_code) - .await - } - - async fn get_device_code(&self) -> Result { - #[derive(Serialize)] - struct DeviceCodeRequest { - client_id: String, - scope: String, - } - self.client - .post(&self.urls.device_code_url) - .headers(self.get_github_headers()) - .json(&DeviceCodeRequest { - client_id: self.client_id.clone(), - scope: "read:user".to_string(), - }) - .send() - .await - .context("failed to send request to get device code")? - .error_for_status() - .context("failed to get device code")? - .json::() - .await - .context("failed to parse device code response") - } - - async fn poll_for_access_token(&self, device_code: &str) -> Result { - #[derive(Serialize)] - struct AccessTokenRequest { - client_id: String, - device_code: String, - grant_type: String, - } - #[derive(Debug, Deserialize)] - struct AccessTokenResponse { - access_token: Option, - error: Option, - #[serde(flatten)] - _extra: HashMap, - } - - const MAX_ATTEMPTS: i32 = 36; - for attempt in 0..MAX_ATTEMPTS { - let resp = self - .client - .post(&self.urls.access_token_url) - .headers(self.get_github_headers()) - .json(&AccessTokenRequest { - client_id: self.client_id.clone(), - device_code: device_code.to_string(), - grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(), - }) - .send() - .await - .context("failed to make request while polling for access token")? - .error_for_status() - .context("error polling for access token")? - .json::() - .await - .context("failed to parse response while polling for access token")?; - if resp.access_token.is_some() { - tracing::trace!("successful authorization: {:#?}", resp,); - } - if let Some(access_token) = resp.access_token { - return Ok(access_token); - } else if resp - .error - .as_ref() - .is_some_and(|err| err == "authorization_pending") - { - tracing::debug!( - "authorization pending (attempt {}/{})", - attempt + 1, - MAX_ATTEMPTS - ); - } else { - tracing::debug!("unexpected response: {:#?}", resp); - } - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - Err(anyhow!("failed to get access token")) + let cfg = DeviceFlowConfig { + device_auth_url: Some(&self.urls.device_code_url), + token_url: &self.urls.access_token_url, + client_id: &self.client_id, + scopes: Some("read:user"), + extra_headers: self.get_github_headers(), + encoding: RequestEncoding::Json, + }; + let tokens = run_device_flow(&self.client, &cfg).await?; + Ok(tokens.access_token) } fn get_github_headers(&self) -> http::HeaderMap { @@ -529,7 +434,7 @@ impl Provider for GithubCopilotProvider { .with_retry(|| async { let mut payload_clone = payload.clone(); let resp = self.post(Some(session_id), &mut payload_clone).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs index 1dbc533591f6..2824bf1a453e 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose/src/providers/google.rs @@ -1,7 +1,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::MessageStream; use super::errors::ProviderError; -use super::openai_compatible::handle_status_openai_compat; +use super::openai_compatible::handle_status; use super::retry::ProviderRetry; use super::utils::RequestLog; use crate::conversation::message::Message; @@ -101,7 +101,7 @@ impl GoogleProvider { .api_client .response_post(session_id, &path, payload) .await?; - handle_status_openai_compat(response).await + handle_status(response).await } } diff --git a/crates/goose/src/providers/http_status.rs b/crates/goose/src/providers/http_status.rs new file mode 100644 index 000000000000..fe2751c28e48 --- /dev/null +++ b/crates/goose/src/providers/http_status.rs @@ -0,0 +1,115 @@ +//! Format-agnostic HTTP status → `ProviderError` mapping. +//! +//! Used by providers regardless of their wire format (OpenAI, Anthropic, +//! Google, etc.). Parses both `{"error":{"message":"..."}}` and +//! `{"message":"..."}` error shapes. + +use reqwest::{Response, StatusCode}; +use serde_json::Value; + +use super::errors::ProviderError; + +fn check_context_length_exceeded(text: &str) -> bool { + let check_phrases = [ + "too long", + "context length", + "context_length_exceeded", + "reduce the length", + "token count", + "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", + "maximum prompt length", + ]; + let text_lower = text.to_lowercase(); + check_phrases + .iter() + .any(|phrase| text_lower.contains(phrase)) +} + +pub fn map_http_error_to_provider_error( + status: StatusCode, + payload: Option, +) -> ProviderError { + let extract_message = || -> String { + payload + .as_ref() + .and_then(|p| { + p.get("error") + .and_then(|e| e.get("message")) + .or_else(|| p.get("message")) + .and_then(|m| m.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| payload.as_ref().map(|p| p.to_string()).unwrap_or_default()) + }; + + let error = match status { + StatusCode::OK => unreachable!("Should not call this function with OK status"), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::Authentication(format!( + "Authentication failed. Status: {}. Response: {}", + status, + extract_message() + )), + StatusCode::NOT_FOUND => { + ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message())) + } + StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted { + details: extract_message(), + top_up_url: None, + }, + StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), + StatusCode::BAD_REQUEST => { + let payload_str = extract_message(); + if check_context_length_exceeded(&payload_str) { + ProviderError::ContextLengthExceeded(payload_str) + } else { + ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) + } + } + StatusCode::TOO_MANY_REQUESTS => ProviderError::RateLimitExceeded { + details: extract_message(), + retry_delay: None, + }, + _ if status.is_server_error() => { + ProviderError::ServerError(format!("Server error ({}): {}", status, extract_message())) + } + _ => ProviderError::RequestFailed(format!( + "Request failed with status {}: {}", + status, + extract_message() + )), + }; + + if !status.is_success() { + tracing::warn!( + "Provider request failed with status: {}. Payload: {:?}. Returning error: {:?}", + status, + payload, + error + ); + } + + error +} + +pub async fn handle_status(response: Response) -> Result { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let payload = serde_json::from_str::(&body).ok(); + return Err(map_http_error_to_provider_error(status, payload)); + } + Ok(response) +} + +pub async fn handle_response(response: Response) -> Result { + let response = handle_status(response).await?; + + response.json::().await.map_err(|e| { + ProviderError::RequestFailed(format!("Response body is not valid JSON: {}", e)) + }) +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index c0c228bbcfea..b9189a99f11f 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -100,6 +100,10 @@ async fn init_registry() -> RwLock { "kimi_code", Arc::new(|| Box::pin(KimiCodeProvider::cleanup())), ); + registry.set_cleanup( + "chatgpt_codex", + Arc::new(|| Box::pin(ChatGptCodexProvider::cleanup())), + ); if let Err(e) = load_custom_providers_into_registry(&mut registry) { tracing::warn!("Failed to load custom providers: {}", e); @@ -145,6 +149,10 @@ pub async fn get_from_registry(name: &str) -> Result { .cloned() } +pub async fn inventory_identity(name: &str) -> Result { + get_from_registry(name).await?.inventory_identity() +} + pub async fn create( name: &str, model: ModelConfig, @@ -230,6 +238,41 @@ mod tests { assert!(!endpoint.secret, "Endpoint should not be secret"); } + #[tokio::test] + async fn test_nvidia_declarative_provider_registry_wiring() { + let nvidia = get_from_registry("nvidia") + .await + .expect("nvidia provider should be registered"); + let meta = nvidia.metadata(); + + assert_eq!(nvidia.provider_type(), ProviderType::Declarative); + assert!(nvidia.supports_inventory_refresh()); + assert_eq!(meta.display_name, "NVIDIA"); + assert_eq!(meta.default_model, "z-ai/glm-4.7"); + assert_eq!(meta.model_doc_link, "https://build.nvidia.com/models"); + assert!(!meta.setup_steps.is_empty()); + + let api_key = meta + .config_keys + .iter() + .find(|k| k.name == "NVIDIA_API_KEY") + .expect("NVIDIA_API_KEY config key should exist"); + assert!(api_key.required, "NVIDIA_API_KEY should be required"); + assert!(api_key.secret, "NVIDIA_API_KEY should be secret"); + assert!(api_key.primary, "NVIDIA_API_KEY should be primary"); + assert!( + !meta.config_keys.iter().any(|k| k.name == "OPENAI_HOST"), + "NVIDIA should not expose OpenAI host configuration" + ); + assert!( + !meta + .config_keys + .iter() + .any(|k| k.name == "OPENAI_BASE_PATH"), + "NVIDIA should not expose OpenAI base path configuration" + ); + } + #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs new file mode 100644 index 000000000000..7db08a06b26d --- /dev/null +++ b/crates/goose/src/providers/inventory/mod.rs @@ -0,0 +1,990 @@ +use super::base::{ConfigKey, ModelInfo, ProviderType}; +use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry}; +use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; +use crate::config::Config; +use crate::session::session_manager::SessionStorage; +use anyhow::Result; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Row, Sqlite, Transaction}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; + +const STALE_AFTER_HOURS: i64 = 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryEntry { + pub provider_id: String, + pub provider_name: String, + pub description: String, + pub default_model: String, + pub configured: bool, + pub provider_type: ProviderType, + pub config_keys: Vec, + pub setup_steps: Vec, + pub supports_refresh: bool, + pub refreshing: bool, + pub models: Vec, + pub last_updated_at: Option>, + pub last_refresh_attempt_at: Option>, + pub last_refresh_error: Option, + pub model_selection_hint: Option, +} + +/// Families whose latest model should be surfaced in the compact picker. +/// Each entry is matched against the `family` field of enriched models. +const RECOMMENDED_FAMILIES: &[&str] = &[ + "claude-opus", + "claude-sonnet", + "gpt", + "gpt-mini", + "glm", + "gemini-pro", + "gemini-flash", + "gemma", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InventoryModel { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Whether this model should appear in the compact recommended picker. + pub recommended: bool, +} + +#[derive(Debug, Clone)] +pub struct InventoryIdentity { + pub provider_id: String, + pub provider_family: String, + pub inventory_key: String, +} + +#[derive(Debug, Clone, Default)] +pub struct InventoryIdentityInput { + pub provider_id: String, + pub provider_family: String, + pub public_inputs: BTreeMap, + pub secret_inputs: BTreeMap, +} + +impl InventoryIdentityInput { + pub fn new( + provider_id: impl Into, + provider_family: impl Into, + ) -> InventoryIdentityInput { + InventoryIdentityInput { + provider_id: provider_id.into(), + provider_family: provider_family.into(), + public_inputs: BTreeMap::new(), + secret_inputs: BTreeMap::new(), + } + } + + pub fn with_public( + mut self, + key: impl Into, + value: impl Into, + ) -> InventoryIdentityInput { + self.public_inputs.insert(key.into(), value.into()); + self + } + + pub fn with_secret( + mut self, + key: impl Into, + value: impl Into, + ) -> InventoryIdentityInput { + self.secret_inputs.insert(key.into(), value.into()); + self + } + + pub fn into_identity(self) -> Result { + let InventoryIdentityInput { + provider_id, + provider_family, + public_inputs, + secret_inputs, + } = self; + let payload = serde_json::json!({ + "provider_family": provider_family, + "public_inputs": public_inputs, + "secret_inputs": secret_inputs, + }); + let digest = Sha256::digest(serde_json::to_vec(&payload)?); + Ok(InventoryIdentity { + provider_id, + provider_family, + inventory_key: format!("{digest:x}"), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshSkipReason { + UnknownProvider, + NotConfigured, + DoesNotSupportRefresh, + AlreadyRefreshing, +} + +#[derive(Debug, Clone)] +pub struct RefreshSkip { + pub provider_id: String, + pub reason: RefreshSkipReason, +} + +#[derive(Debug, Clone, Default)] +pub struct RefreshPlan { + pub started: Vec, + pub skipped: Vec, +} + +#[derive(Clone)] +pub struct ProviderInventoryService { + storage: Arc, + refreshing_keys: Arc>>, +} + +#[derive(Debug, Clone)] +struct InventorySnapshot { + models: Vec, + last_updated_at: Option>, + last_refresh_attempt_at: Option>, + last_refresh_error: Option, +} + +#[derive(Debug, Clone)] +struct ProviderDescriptor { + provider_id: String, + provider_name: String, + description: String, + default_model: String, + identity: InventoryIdentity, + configured: bool, + provider_type: ProviderType, + config_keys: Vec, + setup_steps: Vec, + supports_refresh: bool, + static_models: Vec, + model_selection_hint: Option, +} + +impl ProviderInventoryService { + pub fn new(storage: Arc) -> ProviderInventoryService { + ProviderInventoryService { + storage, + refreshing_keys: Arc::new(RwLock::new(HashSet::new())), + } + } + + pub async fn entry_for_provider( + &self, + provider_id: &str, + ) -> Result> { + let Some(descriptor) = self.describe_provider(provider_id).await? else { + return Ok(None); + }; + let snapshot = self.read_snapshot(&descriptor.identity).await?; + let refreshing = self + .refreshing_keys + .read() + .await + .contains(&descriptor.identity.inventory_key); + let models = inventory_models_from_snapshot( + snapshot.as_ref(), + &descriptor.identity.provider_family, + &descriptor.static_models, + ); + + Ok(Some(ProviderInventoryEntry { + provider_id: descriptor.provider_id, + provider_name: descriptor.provider_name, + description: descriptor.description, + default_model: descriptor.default_model, + configured: descriptor.configured, + provider_type: descriptor.provider_type, + config_keys: descriptor.config_keys, + setup_steps: descriptor.setup_steps, + supports_refresh: descriptor.supports_refresh, + refreshing, + models, + last_updated_at: snapshot + .as_ref() + .and_then(|snapshot| snapshot.last_updated_at), + last_refresh_attempt_at: snapshot + .as_ref() + .and_then(|snapshot| snapshot.last_refresh_attempt_at), + last_refresh_error: snapshot.and_then(|snapshot| snapshot.last_refresh_error), + model_selection_hint: descriptor.model_selection_hint, + })) + } + + pub async fn entries(&self, provider_ids: &[String]) -> Result> { + let ids = self.resolve_provider_ids(provider_ids).await; + let mut entries = Vec::with_capacity(ids.len()); + for provider_id in ids { + if let Some(entry) = self.entry_for_provider(&provider_id).await? { + entries.push(entry); + } + } + Ok(entries) + } + + pub async fn plan_refresh(&self, provider_ids: &[String]) -> Result { + let ids = self.resolve_provider_ids(provider_ids).await; + let mut plan = RefreshPlan::default(); + + for provider_id in ids { + let Some(descriptor) = self.describe_provider(&provider_id).await? else { + plan.skipped.push(RefreshSkip { + provider_id, + reason: RefreshSkipReason::UnknownProvider, + }); + continue; + }; + + if !descriptor.supports_refresh { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::DoesNotSupportRefresh, + }); + continue; + } + + if !descriptor.configured { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::NotConfigured, + }); + continue; + } + + let mut refreshing_keys = self.refreshing_keys.write().await; + if refreshing_keys.contains(&descriptor.identity.inventory_key) { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::AlreadyRefreshing, + }); + continue; + } + + refreshing_keys.insert(descriptor.identity.inventory_key.clone()); + drop(refreshing_keys); + + self.mark_refresh_started(&descriptor.identity).await?; + plan.started.push(descriptor.provider_id); + } + + Ok(plan) + } + + pub async fn store_refreshed_models( + &self, + provider_id: &str, + model_ids: &[String], + ) -> Result<()> { + let descriptor = self.require_provider(provider_id).await?; + let models = + enrich_model_ids_with_canonical(&descriptor.identity.provider_family, model_ids); + let now = Utc::now(); + let pool = self.storage.pool().await?; + let mut tx = pool.begin().await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = NULL, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(&descriptor.identity.provider_id) + .bind(&descriptor.identity.provider_family) + .bind(now.to_rfc3339()) + .bind(now.to_rfc3339()) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM provider_inventory_models WHERE inventory_key = ?") + .bind(&descriptor.identity.inventory_key) + .execute(&mut *tx) + .await?; + + for (ordinal, model) in models.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO provider_inventory_models ( + inventory_key, + ordinal, + model_id, + name, + family, + context_limit, + reasoning, + recommended + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(i64::try_from(ordinal)?) + .bind(&model.id) + .bind(&model.name) + .bind(&model.family) + .bind(model.context_limit.map(i64::try_from).transpose()?) + .bind(model.reasoning) + .bind(model.recommended) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + self.refreshing_keys + .write() + .await + .remove(&descriptor.identity.inventory_key); + Ok(()) + } + + pub async fn store_refresh_error( + &self, + provider_id: &str, + error: impl Into, + ) -> Result<()> { + let descriptor = self.require_provider(provider_id).await?; + let error = error.into(); + let existing = self.read_snapshot(&descriptor.identity).await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = excluded.last_refresh_error, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(&descriptor.identity.provider_id) + .bind(&descriptor.identity.provider_family) + .bind(existing.and_then(|snapshot| snapshot.last_updated_at.map(|time| time.to_rfc3339()))) + .bind(Utc::now().to_rfc3339()) + .bind(error) + .execute(self.storage.pool().await?) + .await?; + + self.refreshing_keys + .write() + .await + .remove(&descriptor.identity.inventory_key); + Ok(()) + } + + pub fn is_stale(entry: &ProviderInventoryEntry) -> bool { + let Some(last_updated_at) = entry.last_updated_at else { + return false; + }; + entry.supports_refresh && Utc::now() - last_updated_at > Duration::hours(STALE_AFTER_HOURS) + } + + async fn describe_provider(&self, provider_id: &str) -> Result> { + let entry = match crate::providers::get_from_registry(provider_id).await { + Ok(entry) => entry, + Err(_) => return Ok(None), + }; + let metadata = entry.metadata().clone(); + let identity = crate::providers::inventory_identity(provider_id) + .await + .unwrap_or_else(|_| fallback_inventory_identity(provider_id)) + .into_identity()?; + + Ok(Some(ProviderDescriptor { + provider_id: metadata.name.clone(), + provider_name: metadata.display_name.clone(), + description: metadata.description.clone(), + default_model: metadata.default_model.clone(), + identity, + configured: entry.inventory_configured(), + provider_type: entry.provider_type(), + config_keys: metadata.config_keys.clone(), + setup_steps: metadata.setup_steps.clone(), + supports_refresh: entry.supports_inventory_refresh(), + static_models: metadata.known_models, + model_selection_hint: metadata.model_selection_hint, + })) + } + + async fn require_provider(&self, provider_id: &str) -> Result { + self.describe_provider(provider_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", provider_id)) + } + + async fn mark_refresh_started(&self, identity: &InventoryIdentity) -> Result<()> { + let existing = self.read_snapshot(identity).await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = NULL, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&identity.inventory_key) + .bind(&identity.provider_id) + .bind(&identity.provider_family) + .bind(existing.and_then(|snapshot| snapshot.last_updated_at.map(|time| time.to_rfc3339()))) + .bind(Utc::now().to_rfc3339()) + .execute(self.storage.pool().await?) + .await?; + + Ok(()) + } + + async fn read_snapshot( + &self, + identity: &InventoryIdentity, + ) -> Result> { + let pool = self.storage.pool().await?; + let entry = sqlx::query( + r#" + SELECT last_updated_at, last_refresh_attempt_at, last_refresh_error + FROM provider_inventory_entries + WHERE inventory_key = ? + "#, + ) + .bind(&identity.inventory_key) + .fetch_optional(pool) + .await?; + + let Some(entry) = entry else { + return Ok(None); + }; + + let last_updated_at = parse_optional_datetime(entry.try_get("last_updated_at")?)?; + let last_refresh_attempt_at = + parse_optional_datetime(entry.try_get("last_refresh_attempt_at")?)?; + let last_refresh_error = entry.try_get("last_refresh_error")?; + + let rows = sqlx::query( + r#" + SELECT model_id, name, family, context_limit, reasoning, recommended + FROM provider_inventory_models + WHERE inventory_key = ? + ORDER BY ordinal + "#, + ) + .bind(&identity.inventory_key) + .fetch_all(pool) + .await?; + + let models = rows + .into_iter() + .map(|row| { + Ok(InventoryModel { + id: row.try_get("model_id")?, + name: row.try_get("name")?, + family: row.try_get("family")?, + context_limit: row + .try_get::, _>("context_limit")? + .map(usize::try_from) + .transpose()?, + reasoning: row.try_get("reasoning")?, + recommended: row + .try_get::, _>("recommended")? + .unwrap_or(false), + }) + }) + .collect::, anyhow::Error>>()?; + + Ok(Some(InventorySnapshot { + models, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + })) + } + + async fn resolve_provider_ids(&self, provider_ids: &[String]) -> Vec { + let mut ids = if provider_ids.is_empty() { + crate::providers::providers() + .await + .into_iter() + .map(|(metadata, _)| metadata.name) + .collect::>() + } else { + provider_ids.to_vec() + }; + ids.sort(); + ids.dedup(); + ids + } +} + +pub fn default_inventory_identity( + provider_id: &str, + provider_family: &str, + config_keys: &[ConfigKey], + config: &Config, +) -> InventoryIdentityInput { + let mut identity = InventoryIdentityInput::new(provider_id, provider_family); + + for key in config_keys { + if key.secret { + if let Some(value) = config_secret_value(config, &key.name) { + identity.secret_inputs.insert(key.name.clone(), value); + } + } else if let Some(value) = config_param_value(config, &key.name) { + identity.public_inputs.insert(key.name.clone(), value); + } + } + + identity +} + +pub fn default_inventory_configured(config_keys: &[ConfigKey], config: &Config) -> bool { + config_keys.iter().all(|key| { + if !key.required { + return true; + } + if key.default.is_some() { + return true; + } + if key.secret { + config.get_secret::(&key.name).is_ok() + } else { + config.get_param::(&key.name).is_ok() + } + }) +} + +pub fn declarative_inventory_identity( + config: &DeclarativeProviderConfig, +) -> Result { + let global = Config::global(); + let mut identity = InventoryIdentityInput::new( + config.name.clone(), + config + .catalog_provider_id + .clone() + .unwrap_or_else(|| match config.engine { + ProviderEngine::OpenAI => "openai".to_string(), + ProviderEngine::Anthropic => "anthropic".to_string(), + ProviderEngine::Ollama => "ollama".to_string(), + }), + ); + + identity + .public_inputs + .insert("base_url".to_string(), config.base_url.clone()); + + if let Some(base_path) = &config.base_path { + identity + .public_inputs + .insert("base_path".to_string(), base_path.clone()); + } + if let Some(catalog_provider_id) = &config.catalog_provider_id { + identity.public_inputs.insert( + "catalog_provider_id".to_string(), + catalog_provider_id.clone(), + ); + } + if let Some(dynamic_models) = config.dynamic_models { + identity + .public_inputs + .insert("dynamic_models".to_string(), dynamic_models.to_string()); + } + identity.public_inputs.insert( + "skip_canonical_filtering".to_string(), + config.skip_canonical_filtering.to_string(), + ); + if !config.models.is_empty() { + identity.public_inputs.insert( + "models".to_string(), + serde_json::to_string( + &config + .models + .iter() + .map(|model| &model.name) + .collect::>(), + )?, + ); + } + if let Some(headers) = &config.headers { + identity + .public_inputs + .insert("headers".to_string(), serialize_string_map(headers)?); + } + if config.requires_auth && !config.api_key_env.is_empty() { + if let Some(value) = config_secret_value(global, &config.api_key_env) { + identity + .secret_inputs + .insert(config.api_key_env.clone(), value); + } + } + + Ok(identity) +} + +pub fn config_param_value(config: &Config, key: &str) -> Option { + config + .get_param::(key) + .ok() + .and_then(|value| normalize_json_value(&value)) +} + +pub fn config_secret_value(config: &Config, key: &str) -> Option { + config + .get_secret::(key) + .ok() + .and_then(|value| normalize_json_value(&value)) +} + +pub fn serialize_string_map(map: &HashMap) -> Result { + let ordered = map + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + Ok(serde_json::to_string(&ordered)?) +} + +fn parse_optional_datetime(value: Option) -> Result>> { + value + .map(|value| value.parse::>()) + .transpose() + .map_err(Into::into) +} + +fn normalize_json_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::String(value) if value.is_empty() => None, + serde_json::Value::String(value) => Some(value.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn fallback_inventory_identity(provider_id: &str) -> InventoryIdentityInput { + InventoryIdentityInput::new( + provider_id.to_string(), + map_provider_name(provider_id).to_string(), + ) +} + +fn enrich_model_ids_with_canonical( + provider_family: &str, + model_ids: &[String], +) -> Vec { + let mut models: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + + for id in model_ids { + let model = enriched_model(provider_family, id, None); + if !seen_names.insert(model.name.clone()) { + continue; + } + models.push(model); + } + + // For databricks, prefer goose- prefixed model_ids when there are duplicates. + // Re-scan: if a later model_id with "goose-" prefix maps to the same display name, + // swap it in. + if provider_family == "databricks" { + let mut name_to_idx: HashMap = HashMap::new(); + for (idx, model) in models.iter().enumerate() { + name_to_idx.insert(model.name.clone(), idx); + } + for id in model_ids { + if !id.starts_with("goose-") { + continue; + } + let candidate = enriched_model(provider_family, id, None); + if let Some(&idx) = name_to_idx.get(&candidate.name) { + if !models[idx].id.starts_with("goose-") { + models[idx].id = candidate.id; + } + } + } + } + + // Mark the latest model per recommended family. + let mut seen_recommended_families: HashSet = HashSet::new(); + for model in &mut models { + if let Some(family) = &model.family { + if RECOMMENDED_FAMILIES.contains(&family.as_str()) + && seen_recommended_families.insert(family.clone()) + { + model.recommended = true; + } + } + } + + models +} + +fn configured_models_to_inventory( + provider_family: &str, + models: &[ModelInfo], +) -> Vec { + let mut result: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + for model in models { + let enriched = enriched_model(provider_family, &model.name, Some(model.context_limit)); + if seen_names.insert(enriched.name.clone()) { + result.push(enriched); + } + } + + let mut seen_recommended_families: HashSet = HashSet::new(); + for model in &mut result { + if let Some(family) = &model.family { + if RECOMMENDED_FAMILIES.contains(&family.as_str()) + && seen_recommended_families.insert(family.clone()) + { + model.recommended = true; + } + } + } + + result +} + +fn inventory_models_from_snapshot( + snapshot: Option<&InventorySnapshot>, + provider_family: &str, + configured_models: &[ModelInfo], +) -> Vec { + match snapshot { + Some(snapshot) if !snapshot.models.is_empty() || snapshot.last_updated_at.is_some() => { + snapshot.models.clone() + } + _ => configured_models_to_inventory(provider_family, configured_models), + } +} + +fn enriched_model( + provider_family: &str, + model_id: &str, + fallback_context_limit: Option, +) -> InventoryModel { + let registry = CanonicalModelRegistry::bundled().ok(); + let canonical = registry.as_ref().and_then(|registry| { + let canonical_id = map_to_canonical_model(provider_family, model_id, registry)?; + let (provider, model) = canonical_id.split_once('/')?; + registry.get(provider, model).cloned() + }); + + InventoryModel { + id: model_id.to_string(), + name: canonical + .as_ref() + .map(|model| model.name.clone()) + .unwrap_or_else(|| model_id.to_string()), + family: canonical.as_ref().and_then(|model| model.family.clone()), + context_limit: canonical + .as_ref() + .map(|model| model.limit.context) + .or(fallback_context_limit), + reasoning: canonical.as_ref().and_then(|model| model.reasoning), + recommended: false, + } +} + +pub async fn create_tables(pool: &Pool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_entries ( + inventory_key TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + provider_family TEXT NOT NULL, + last_updated_at TEXT, + last_refresh_attempt_at TEXT, + last_refresh_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_models ( + inventory_key TEXT NOT NULL REFERENCES provider_inventory_entries(inventory_key) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL, + family TEXT, + context_limit INTEGER, + reasoning BOOLEAN, + recommended BOOLEAN, + PRIMARY KEY (inventory_key, ordinal) + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_provider_inventory_provider_id ON provider_inventory_entries(provider_id)", + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn create_tables_in_tx(tx: &mut Transaction<'_, Sqlite>) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_entries ( + inventory_key TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + provider_family TEXT NOT NULL, + last_updated_at TEXT, + last_refresh_attempt_at TEXT, + last_refresh_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&mut **tx) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_models ( + inventory_key TEXT NOT NULL REFERENCES provider_inventory_entries(inventory_key) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL, + family TEXT, + context_limit INTEGER, + reasoning BOOLEAN, + recommended BOOLEAN, + PRIMARY KEY (inventory_key, ordinal) + ) + "#, + ) + .execute(&mut **tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_provider_inventory_provider_id ON provider_inventory_entries(provider_id)", + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inventory_identity_hash_changes_with_secret_inputs() { + let left = InventoryIdentityInput::new("openai", "openai") + .with_public("host", "https://api.openai.com") + .with_secret("api_key", "secret-a") + .into_identity() + .unwrap(); + let right = InventoryIdentityInput::new("openai", "openai") + .with_public("host", "https://api.openai.com") + .with_secret("api_key", "secret-b") + .into_identity() + .unwrap(); + + assert_ne!(left.inventory_key, right.inventory_key); + } + + #[test] + fn configured_models_use_canonical_enrichment() { + let models = + configured_models_to_inventory("anthropic", &[ModelInfo::new("claude-sonnet-4-5", 0)]); + + assert_eq!(models.len(), 1); + assert!(models[0].name.contains("Claude")); + } + + #[test] + fn inventory_uses_configured_models_before_first_successful_refresh() { + let configured_models = [ModelInfo::new("claude-sonnet-4-5", 0)]; + let snapshot = InventorySnapshot { + models: vec![], + last_updated_at: None, + last_refresh_attempt_at: Some(Utc::now()), + last_refresh_error: Some("auth failed".to_string()), + }; + + let models = + inventory_models_from_snapshot(Some(&snapshot), "anthropic", &configured_models); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "claude-sonnet-4-5"); + } + + #[test] + fn inventory_preserves_empty_models_after_successful_refresh() { + let configured_models = [ModelInfo::new("claude-sonnet-4-5", 0)]; + let snapshot = InventorySnapshot { + models: vec![], + last_updated_at: Some(Utc::now()), + last_refresh_attempt_at: Some(Utc::now()), + last_refresh_error: None, + }; + + let models = + inventory_models_from_snapshot(Some(&snapshot), "anthropic", &configured_models); + + assert!(models.is_empty()); + } +} diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 88065d888c53..2a46e14f8a09 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -1,7 +1,7 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::session_context::SESSION_ID_HEADER; -use anyhow::{anyhow, Context, Result}; +use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; @@ -19,7 +19,10 @@ use uuid::Uuid; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; use super::formats::anthropic::{create_request, response_to_streaming_message}; -use super::openai_compatible::handle_status_openai_compat; +use super::oauth_device_flow::{ + refresh_device_flow_token, run_device_flow, DeviceFlowConfig, DeviceFlowTokens, RequestEncoding, +}; +use super::openai_compatible::handle_status; use super::retry::ProviderRetry; use super::utils::RequestLog; use crate::conversation::message::Message; @@ -49,16 +52,6 @@ const REFRESH_THRESHOLD_SECS: i64 = 300; /// Fallback access-token lifetime when the server omits `expires_in`. const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 3600; -/// Fallback device-code window when the server omits `expires_in` -/// from `device_authorization`. -const DEFAULT_DEVICE_CODE_LIFETIME_SECS: u64 = 300; - -/// Fallback poll interval when the server omits `interval`. -const DEFAULT_POLL_INTERVAL_SECS: u64 = 5; - -/// Extra seconds added to the poll interval after an RFC 8628 `slow_down`. -const SLOW_DOWN_BACKOFF_SECS: u64 = 5; - /// Marker key written to the user config when OAuth completes successfully. /// `check_provider_configured` (server) keys off this when an OAuth-flow /// provider has no required secret env var. @@ -73,6 +66,24 @@ struct KimiToken { expires_at: DateTime, } +/// Normalize helper output into the on-disk `KimiToken` shape. When the helper +/// returns `None` for `refresh_token` or `expires_at`, fall back to the prior +/// refresh token (per RFC 6749 §6) and a default lifetime. +fn tokens_to_kimi(tokens: DeviceFlowTokens, prior_refresh: Option<&str>) -> KimiToken { + let refresh_token = tokens + .refresh_token + .or_else(|| prior_refresh.map(str::to_string)) + .unwrap_or_default(); + let expires_at = tokens + .expires_at + .unwrap_or_else(|| Utc::now() + Duration::seconds(DEFAULT_TOKEN_LIFETIME_SECS)); + KimiToken { + access_token: tokens.access_token, + refresh_token, + expires_at, + } +} + #[derive(Debug)] struct TokenCache { path: std::path::PathBuf, @@ -261,201 +272,34 @@ impl KimiCodeProvider { } async fn device_flow_login(&self) -> Result { - #[derive(Serialize)] - struct DeviceAuthReq<'a> { - client_id: &'a str, - } - #[derive(Deserialize)] - struct DeviceAuthResp { - device_code: String, - user_code: String, - verification_uri_complete: Option, - verification_uri: String, - interval: Option, - expires_in: Option, - } - - let resp: DeviceAuthResp = self - .client - .post(format!("{}/api/oauth/device_authorization", self.auth_host)) - .headers(self.kimi_headers()) - .form(&DeviceAuthReq { - client_id: KIMI_CODE_CLIENT_ID, - }) - .send() - .await - .context("failed to request device authorization")? - .error_for_status() - .context("device authorization request failed")? - .json() - .await - .context("failed to parse device authorization response")?; - - let verify_url = resp - .verification_uri_complete - .as_deref() - .unwrap_or(&resp.verification_uri); - let interval = resp.interval.unwrap_or(DEFAULT_POLL_INTERVAL_SECS); - - if let Ok(mut clipboard) = arboard::Clipboard::new() { - let _ = clipboard.set_text(&resp.user_code); - } - if let Err(e) = webbrowser::open(verify_url) { - tracing::warn!("Failed to open browser: {}", e); - } - - // stderr so CLI workflows parsing stdout aren't interfered with. - eprintln!( - "Please visit {} and enter code {}", - verify_url, resp.user_code - ); - - let expires_in = resp.expires_in.unwrap_or(DEFAULT_DEVICE_CODE_LIFETIME_SECS); - self.poll_for_token(&resp.device_code, interval, expires_in) - .await - } - - async fn poll_for_token( - &self, - device_code: &str, - interval_secs: u64, - expires_in_secs: u64, - ) -> Result { - #[derive(Serialize)] - struct PollReq<'a> { - client_id: &'a str, - device_code: &'a str, - grant_type: &'static str, - } - #[derive(Deserialize, Debug)] - struct PollResp { - access_token: Option, - refresh_token: Option, - expires_in: Option, - error: Option, - } - - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(expires_in_secs); - let mut effective_interval = interval_secs; - loop { - if tokio::time::Instant::now() >= deadline { - return Err(anyhow!("timed out waiting for user authorization")); - } - tokio::time::sleep(tokio::time::Duration::from_secs(effective_interval)).await; - - let response = self - .client - .post(format!("{}/api/oauth/token", self.auth_host)) - .headers(self.kimi_headers()) - .form(&PollReq { - client_id: KIMI_CODE_CLIENT_ID, - device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }) - .send() - .await - .context("failed to poll for token")?; - - // RFC 8628 returns pending/slow_down as 4xx with a JSON error payload, - // so don't `error_for_status()` before parsing — but if the body is - // unparseable AND the status is non-2xx, surface the HTTP status. - let status = response.status(); - let bytes = response - .bytes() - .await - .context("failed to read token poll response")?; - let resp: PollResp = match serde_json::from_slice(&bytes) { - Ok(p) => p, - Err(e) => { - if !status.is_success() { - return Err(anyhow!( - "token poll HTTP {}: {}", - status, - String::from_utf8_lossy(&bytes) - )); - } - return Err( - anyhow::Error::new(e).context("failed to parse token poll response") - ); - } - }; - - if let Some(access_token) = resp.access_token { - // RFC 6749: refresh_token is optional in token responses. - // Kimi currently returns one, but be defensive for servers/ - // versions that do not. - let refresh_token = resp.refresh_token.unwrap_or_default(); - let expires_in = resp.expires_in.unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS); - return Ok(KimiToken { - access_token, - refresh_token, - expires_at: Utc::now() + Duration::seconds(expires_in), - }); - } - - match resp.error.as_deref() { - Some("authorization_pending") => { - tracing::debug!("authorization pending, continuing to poll"); - } - // RFC 8628: client MUST increase polling interval by 5 seconds - Some("slow_down") => { - tracing::debug!("slow_down received, increasing poll interval"); - effective_interval += SLOW_DOWN_BACKOFF_SECS; - } - Some(err) => { - return Err(anyhow!("authorization failed: {}", err)); - } - None => { - tracing::debug!("unexpected poll response: no token and no error"); - } - } - } + let device_auth_url = format!("{}/api/oauth/device_authorization", self.auth_host); + let token_url = format!("{}/api/oauth/token", self.auth_host); + let cfg = DeviceFlowConfig { + device_auth_url: Some(&device_auth_url), + token_url: &token_url, + client_id: KIMI_CODE_CLIENT_ID, + scopes: None, + extra_headers: self.kimi_headers(), + encoding: RequestEncoding::Form, + }; + let tokens = run_device_flow(&self.client, &cfg).await?; + Ok(tokens_to_kimi(tokens, None)) } async fn do_refresh_token(&self, refresh_token: &str) -> Result { - #[derive(Serialize)] - struct RefreshReq<'a> { - client_id: &'a str, - grant_type: &'static str, - refresh_token: &'a str, - } - #[derive(Deserialize)] - struct RefreshResp { - access_token: String, - refresh_token: Option, - expires_in: Option, - } - - let resp: RefreshResp = self - .client - .post(format!("{}/api/oauth/token", self.auth_host)) - .headers(self.kimi_headers()) - .form(&RefreshReq { - client_id: KIMI_CODE_CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - }) - .send() - .await - .context("failed to refresh token")? - .error_for_status() - .context("token refresh failed")? - .json() - .await - .context("failed to parse token refresh response")?; - + let token_url = format!("{}/api/oauth/token", self.auth_host); + let cfg = DeviceFlowConfig { + device_auth_url: None, + token_url: &token_url, + client_id: KIMI_CODE_CLIENT_ID, + scopes: None, + extra_headers: self.kimi_headers(), + encoding: RequestEncoding::Form, + }; + let tokens = refresh_device_flow_token(&self.client, &cfg, refresh_token).await?; // RFC 6749 §6: the server MAY omit `refresh_token` from a refresh // response, in which case the client should keep reusing the prior one. - let next_refresh_token = resp - .refresh_token - .unwrap_or_else(|| refresh_token.to_string()); - let expires_in = resp.expires_in.unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS); - Ok(KimiToken { - access_token: resp.access_token, - refresh_token: next_refresh_token, - expires_at: Utc::now() + Duration::seconds(expires_in), - }) + Ok(tokens_to_kimi(tokens, Some(refresh_token))) } // ── HTTP ───────────────────────────────────────────────────────────────── @@ -559,7 +403,7 @@ impl Provider for KimiCodeProvider { let response = self .with_retry(|| async { let resp = self.post(Some(session_id), &payload).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -610,7 +454,7 @@ impl Provider for KimiCodeProvider { .send() .await .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; - let resp = handle_status_openai_compat(resp).await?; + let resp = handle_status(resp).await?; let parsed: ModelsResp = resp.json().await.map_err(|e| { ProviderError::RequestFailed(format!("/v1/models body is not valid JSON: {}", e)) @@ -808,55 +652,10 @@ mod tests { assert_eq!(usable.refresh_token, "new_refresh"); } - #[tokio::test] - async fn poll_for_token_handles_authorization_pending_then_success() { - let server = MockServer::start().await; - - // First call: authorization_pending (returned as 400 per RFC 8628). - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": "authorization_pending", - }))) - .up_to_n_times(1) - .mount(&server) - .await; - - // Subsequent call: token issued. - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "the_token", - "refresh_token": "the_refresh", - "expires_in": 1800, - }))) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let token = provider.poll_for_token("device-abc", 0, 30).await.unwrap(); - assert_eq!(token.access_token, "the_token"); - assert_eq!(token.refresh_token, "the_refresh"); - } - - #[tokio::test] - async fn poll_for_token_accepts_response_without_refresh_token() { - // RFC 6749: refresh_token is optional in token responses. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "access_only", - "expires_in": 1800, - }))) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let token = provider.poll_for_token("device-abc", 0, 5).await.unwrap(); - assert_eq!(token.access_token, "access_only"); - assert_eq!(token.refresh_token, ""); - } + // NOTE: RFC 8628 polling behavior (authorization_pending, slow_down, missing + // refresh_token, HTTP errors during polling) is covered by + // `providers::oauth_device_flow` tests. Tests here focus on Kimi-specific + // integration — token cache, refresh-fallback when server omits refresh_token. #[tokio::test] async fn use_or_refresh_preserves_refresh_token_when_server_omits_it() { @@ -885,29 +684,6 @@ mod tests { assert_eq!(usable.refresh_token, "original_refresh"); } - #[tokio::test] - async fn poll_for_token_surfaces_http_error_on_unparseable_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let err = provider - .poll_for_token("device-abc", 0, 5) - .await - .unwrap_err(); - let msg = format!("{:#}", err); - assert!(msg.contains("502"), "expected status in error: {}", msg); - assert!( - msg.contains("Bad Gateway"), - "expected body in error: {}", - msg - ); - } - // ── fetch_supported_models ──────────────────────────────────────────────── async fn seed_fresh_token(provider: &KimiCodeProvider) { diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 616e7c49ff7d..6781c2a986da 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -1,3 +1,4 @@ +mod acp_tooling; pub mod amp_acp; pub mod anthropic; pub mod api_client; @@ -27,13 +28,16 @@ pub mod gemini_cli; pub mod gemini_oauth; pub mod githubcopilot; pub mod google; +pub mod http_status; mod init; +pub mod inventory; pub mod kimicode; pub mod litellm; #[cfg(feature = "local-inference")] pub mod local_inference; pub mod nanogpt; pub mod oauth; +pub mod oauth_device_flow; pub mod ollama; pub mod openai; pub mod openai_compatible; @@ -55,6 +59,6 @@ pub mod xai; pub use init::{ cleanup_provider, create, create_with_default_model, create_with_named_model, - get_from_registry, providers, refresh_custom_providers, + get_from_registry, inventory_identity, providers, refresh_custom_providers, }; pub use retry::{retry_operation, RetryConfig}; diff --git a/crates/goose/src/providers/nanogpt.rs b/crates/goose/src/providers/nanogpt.rs index 7de1a69b1ed5..fa2bb386fd4f 100644 --- a/crates/goose/src/providers/nanogpt.rs +++ b/crates/goose/src/providers/nanogpt.rs @@ -1,7 +1,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; use super::utils::{ImageFormat, RequestLog}; use crate::conversation::message::Message; @@ -191,7 +191,7 @@ impl Provider for NanoGptProvider { .api_client .response_post(Some(session_id), "chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/oauth_device_flow.rs b/crates/goose/src/providers/oauth_device_flow.rs new file mode 100644 index 000000000000..d257d67b2df8 --- /dev/null +++ b/crates/goose/src/providers/oauth_device_flow.rs @@ -0,0 +1,559 @@ +//! Shared OAuth 2.0 Device Authorization Grant (RFC 8628) helper. +//! +//! Used by providers that authenticate via device-code flow (kimicode, +//! githubcopilot). Handles the authorization request, user-interaction UI, +//! polling loop with RFC 8628 `authorization_pending` / `slow_down` semantics, +//! and optional `refresh_token` grant (RFC 6749 §6). + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use reqwest::header::HeaderMap; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +/// Fallback poll interval when the server omits `interval` (RFC 8628 §3.2). +const DEFAULT_POLL_INTERVAL_SECS: u64 = 5; + +/// Fallback device-code window when the server omits `expires_in` (RFC 8628 §3.2). +const DEFAULT_DEVICE_CODE_LIFETIME_SECS: u64 = 300; + +/// Extra seconds added to the poll interval after an RFC 8628 `slow_down`. +const SLOW_DOWN_BACKOFF_SECS: u64 = 5; + +/// How a provider expects the device-authorization and token request bodies to +/// be encoded. RFC 8628 §3.1 specifies `application/x-www-form-urlencoded`, but +/// GitHub accepts JSON when `Accept: application/json` is set. +#[derive(Debug, Clone, Copy)] +pub enum RequestEncoding { + Form, + Json, +} + +/// Connection details for a provider's device flow. +#[derive(Debug, Clone)] +pub struct DeviceFlowConfig<'a> { + /// `device_authorization_endpoint` (RFC 8628 §3.1). + /// `None` when only the refresh grant is needed. + pub device_auth_url: Option<&'a str>, + /// `token_endpoint` used for both device-code polling and refresh grants. + pub token_url: &'a str, + /// Public OAuth client identifier. + pub client_id: &'a str, + /// Space-separated scope string, or `None` to omit the parameter. + pub scopes: Option<&'a str>, + /// Provider-specific headers (user-agent, platform markers, `Accept`, etc.). + pub extra_headers: HeaderMap, + /// Body encoding for device-auth, polling, and refresh requests. + pub encoding: RequestEncoding, +} + +/// Fields returned by `/device_authorization` (RFC 8628 §3.2). +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// Pre-populated URI with user_code embedded, used when the provider + /// supports it (e.g. Kimi). Fall back to `verification_uri` otherwise. + pub verification_uri_complete: Option, + pub interval: Option, + pub expires_in: Option, +} + +impl DeviceCodeResponse { + /// URI the user should visit. Prefers the `_complete` form when present. + pub fn verification_url(&self) -> &str { + self.verification_uri_complete + .as_deref() + .unwrap_or(&self.verification_uri) + } +} + +/// Access + optional refresh credentials from a device-code exchange. +#[derive(Debug, Clone)] +pub struct DeviceFlowTokens { + pub access_token: String, + /// Some providers (GitHub Copilot) do not issue a refresh token. + pub refresh_token: Option, + /// Derived from `expires_in` on the token response. `None` when the server + /// omits it (RFC 6749 §5.1 permits that). + pub expires_at: Option>, +} + +// ── Public entry points ────────────────────────────────────────────────────── + +/// Request a device code from the authorization server. +pub async fn request_device_code( + client: &Client, + cfg: &DeviceFlowConfig<'_>, +) -> Result { + #[derive(Serialize)] + struct DeviceAuthReq<'a> { + client_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, + } + + let body = DeviceAuthReq { + client_id: cfg.client_id, + scope: cfg.scopes, + }; + + let url = cfg + .device_auth_url + .ok_or_else(|| anyhow!("device_auth_url is required for device code request"))?; + send_request(client, cfg, url, &body) + .await + .context("failed to request device authorization")? + .error_for_status() + .context("device authorization request failed")? + .json::() + .await + .context("failed to parse device authorization response") +} + +/// Poll the token endpoint until the user authorizes (or the device code expires). +/// Implements RFC 8628 §3.5 — handles `authorization_pending` and `slow_down`. +pub async fn poll_for_tokens( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + device_code: &str, + interval_secs: u64, + expires_in_secs: u64, +) -> Result { + #[derive(Serialize)] + struct PollReq<'a> { + client_id: &'a str, + device_code: &'a str, + grant_type: &'static str, + } + + let req = PollReq { + client_id: cfg.client_id, + device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }; + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(expires_in_secs); + let mut effective_interval = interval_secs; + + loop { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!("timed out waiting for user authorization")); + } + tokio::time::sleep(tokio::time::Duration::from_secs(effective_interval)).await; + + let response = send_request(client, cfg, cfg.token_url, &req) + .await + .context("failed to poll for token")?; + + // RFC 8628 §3.5 returns pending/slow_down as 4xx with a JSON error + // payload, so don't `error_for_status()` before parsing. If the body + // is unparseable AND the status is non-2xx, surface the HTTP status. + match parse_token_response(response).await? { + TokenPollOutcome::Issued(tokens) => return Ok(tokens), + TokenPollOutcome::Pending => { + tracing::debug!("authorization pending, continuing to poll"); + } + TokenPollOutcome::SlowDown => { + tracing::debug!("slow_down received, increasing poll interval"); + effective_interval += SLOW_DOWN_BACKOFF_SECS; + } + TokenPollOutcome::Failed(err) => { + return Err(anyhow!("authorization failed: {}", err)); + } + } + } +} + +/// High-level flow: request a device code, print user-facing instructions, +/// open the browser, and poll until tokens are issued. +pub async fn run_device_flow( + client: &Client, + cfg: &DeviceFlowConfig<'_>, +) -> Result { + let device = request_device_code(client, cfg).await?; + announce_user_action(&device); + + let interval = device.interval.unwrap_or(DEFAULT_POLL_INTERVAL_SECS); + let expires_in = device + .expires_in + .unwrap_or(DEFAULT_DEVICE_CODE_LIFETIME_SECS); + + poll_for_tokens(client, cfg, &device.device_code, interval, expires_in).await +} + +/// Exchange a refresh token for a new access token (RFC 6749 §6). +pub async fn refresh_device_flow_token( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + refresh_token: &str, +) -> Result { + #[derive(Serialize)] + struct RefreshReq<'a> { + client_id: &'a str, + grant_type: &'static str, + refresh_token: &'a str, + } + + let req = RefreshReq { + client_id: cfg.client_id, + grant_type: "refresh_token", + refresh_token, + }; + + let raw: TokenResponseBody = send_request(client, cfg, cfg.token_url, &req) + .await + .context("failed to refresh token")? + .error_for_status() + .context("token refresh failed")? + .json() + .await + .context("failed to parse token refresh response")?; + + let access_token = raw + .access_token + .ok_or_else(|| anyhow!("refresh response missing access_token"))?; + Ok(DeviceFlowTokens { + access_token, + refresh_token: raw.refresh_token, + expires_at: raw + .expires_in + .map(|secs| Utc::now() + Duration::seconds(secs)), + }) +} + +// ── Internals ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct TokenResponseBody { + access_token: Option, + refresh_token: Option, + expires_in: Option, + error: Option, +} + +enum TokenPollOutcome { + Issued(DeviceFlowTokens), + Pending, + SlowDown, + Failed(String), +} + +async fn parse_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let bytes = response + .bytes() + .await + .context("failed to read token poll response")?; + + let body: TokenResponseBody = match serde_json::from_slice(&bytes) { + Ok(p) => p, + Err(e) => { + if !status.is_success() { + return Err(anyhow!( + "token poll HTTP {}: {}", + status, + String::from_utf8_lossy(&bytes) + )); + } + return Err(anyhow::Error::new(e).context("failed to parse token poll response")); + } + }; + + if let Some(access_token) = body.access_token { + return Ok(TokenPollOutcome::Issued(DeviceFlowTokens { + access_token, + refresh_token: body.refresh_token, + expires_at: body + .expires_in + .map(|secs| Utc::now() + Duration::seconds(secs)), + })); + } + + Ok(match body.error.as_deref() { + Some("authorization_pending") => TokenPollOutcome::Pending, + Some("slow_down") => TokenPollOutcome::SlowDown, + Some(err) => TokenPollOutcome::Failed(err.to_string()), + None => TokenPollOutcome::Failed( + "unexpected token response: no access_token and no error code".to_string(), + ), + }) +} + +async fn send_request( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + url: &str, + body: &T, +) -> reqwest::Result { + let builder = client.post(url).headers(cfg.extra_headers.clone()); + let builder = match cfg.encoding { + RequestEncoding::Form => builder.form(body), + RequestEncoding::Json => builder.json(body), + }; + builder.send().await +} + +fn announce_user_action(device: &DeviceCodeResponse) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Err(e) = clipboard.set_text(&device.user_code) { + tracing::warn!("Failed to copy verification code to clipboard: {}", e); + } + } + let verify_url = device.verification_url(); + if let Err(e) = webbrowser::open(verify_url) { + tracing::warn!("Failed to open browser: {}", e); + } + // stderr keeps stdout clean for CLI workflows parsing provider output. + eprintln!( + "Please visit {} and enter code {}", + verify_url, device.user_code + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_cfg<'a>(device_auth_url: Option<&'a str>, token_url: &'a str) -> DeviceFlowConfig<'a> { + DeviceFlowConfig { + device_auth_url, + token_url, + client_id: "test-client", + scopes: None, + extra_headers: HeaderMap::new(), + encoding: RequestEncoding::Form, + } + } + + #[tokio::test] + async fn poll_returns_issued_tokens_when_server_responds_immediately() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "the_token", + "refresh_token": "the_refresh", + "expires_in": 1800, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "the_token"); + assert_eq!(tokens.refresh_token.as_deref(), Some("the_refresh")); + assert!(tokens.expires_at.is_some()); + } + + #[tokio::test] + async fn poll_handles_authorization_pending_then_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "authorization_pending", + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "issued", + "expires_in": 900, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "issued"); + assert!(tokens.refresh_token.is_none()); + } + + #[tokio::test] + async fn poll_handles_slow_down_then_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "slow_down", + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "issued", + "expires_in": 900, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "issued"); + } + + #[tokio::test] + async fn poll_times_out_when_user_never_authorizes() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "authorization_pending", + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 0) + .await + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "got: {}", err); + } + + #[tokio::test] + async fn poll_surfaces_http_status_on_unparseable_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 5) + .await + .unwrap_err(); + let msg = format!("{:#}", err); + assert!(msg.contains("502"), "expected status in error: {}", msg); + assert!(msg.contains("Bad Gateway"), "expected body: {}", msg); + } + + #[tokio::test] + async fn poll_surfaces_server_error_message() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "access_denied", + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 5) + .await + .unwrap_err(); + assert!(err.to_string().contains("access_denied"), "got: {}", err); + } + + #[tokio::test] + async fn request_device_code_parses_complete_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/device_authorization")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_code": "dc", + "user_code": "UC-1", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=UC-1", + "interval": 3, + "expires_in": 600, + }))) + .mount(&server) + .await; + + let device_url = format!("{}/device_authorization", server.uri()); + let cfg = make_cfg(Some(&device_url), ""); + + let client = Client::new(); + let resp = request_device_code(&client, &cfg).await.unwrap(); + assert_eq!(resp.device_code, "dc"); + assert_eq!(resp.user_code, "UC-1"); + assert_eq!( + resp.verification_url(), + "https://example.com/activate?user_code=UC-1" + ); + assert_eq!(resp.interval, Some(3)); + } + + #[tokio::test] + async fn refresh_token_returns_new_credentials() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new_access", + "refresh_token": "new_refresh", + "expires_in": 3600, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = refresh_device_flow_token(&client, &cfg, "old_refresh") + .await + .unwrap(); + assert_eq!(tokens.access_token, "new_access"); + assert_eq!(tokens.refresh_token.as_deref(), Some("new_refresh")); + } + + #[tokio::test] + async fn refresh_token_allows_server_to_omit_refresh_token() { + // RFC 6749 §6: server MAY omit refresh_token; caller should reuse prior. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new_access", + "expires_in": 3600, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = refresh_device_flow_token(&client, &cfg, "old_refresh") + .await + .unwrap(); + assert_eq!(tokens.access_token, "new_access"); + assert!(tokens.refresh_token.is_none()); + } +} diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 48a063638bdf..e29901d6f362 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -1,7 +1,8 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::handle_status_openai_compat; +use super::inventory::InventoryIdentityInput; +use super::openai_compatible::handle_status; use super::retry::{ProviderRetry, RetryConfig}; use super::utils::{ImageFormat, RequestLog}; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -256,6 +257,22 @@ impl ProviderDef for OllamaProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + Ok( + InventoryIdentityInput::new(OLLAMA_PROVIDER_NAME, OLLAMA_PROVIDER_NAME).with_public( + "host", + config + .get_param::("OLLAMA_HOST") + .unwrap_or_else(|_| OLLAMA_HOST.to_string()), + ), + ) + } } #[async_trait] @@ -307,7 +324,7 @@ impl Provider for OllamaProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index aad6ddd071d6..5eef3badeda4 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -7,8 +7,9 @@ use super::formats::openai_responses::{ create_responses_request, get_responses_usage, responses_api_to_message, responses_api_to_streaming_message, ResponsesApiResponse, }; +use super::inventory::{config_secret_value, InventoryIdentityInput}; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, + handle_response_openai_compat, handle_status, stream_openai_compat, }; use super::retry::ProviderRetry; use super::utils::ImageFormat; @@ -47,9 +48,21 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ("gpt-3.5-turbo", 16_385), ("gpt-4-turbo", 128_000), ("o4-mini", 128_000), + ("gpt-5", 400_000), + ("gpt-5-mini", 400_000), ("gpt-5-nano", 400_000), - ("gpt-5.1-codex", 400_000), + ("gpt-5-pro", 400_000), ("gpt-5-codex", 400_000), + ("gpt-5.1", 400_000), + ("gpt-5.1-codex", 400_000), + ("gpt-5.2", 400_000), + ("gpt-5.2-codex", 400_000), + ("gpt-5.2-pro", 400_000), + ("gpt-5.3-codex", 400_000), + ("gpt-5.4", 1_050_000), + ("gpt-5.4-mini", 400_000), + ("gpt-5.4-nano", 400_000), + ("gpt-5.4-pro", 1_050_000), ]; pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; @@ -71,13 +84,27 @@ pub struct OpenAiProvider { impl OpenAiProvider { pub async fn from_env(model: ModelConfig) -> Result { - let model = model.with_fast(OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_PROVIDER_NAME)?; - let config = crate::config::Config::global(); let host: String = config .get_param("OPENAI_HOST") .unwrap_or_else(|_| "https://api.openai.com".to_string()); + // Only apply the default fast model when talking to OpenAI directly. + // Custom/compatible endpoints likely don't serve gpt-4o-mini, so + // leave fast_model unset (complete_fast will fall back to the main model). + // Parse the URL and compare the hostname exactly to avoid false positives + // (e.g. https://api.openai.com.local:8000 or proxy paths containing api.openai.com). + let is_openai = url::Url::parse(&host) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())) + .map(|h| h == "api.openai.com" || h.ends_with(".api.openai.com")) + .unwrap_or(false); + let model = if is_openai { + model.with_fast(OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_PROVIDER_NAME)? + } else { + model + }; + let secrets = config .get_secrets("OPENAI_API_KEY", &["OPENAI_CUSTOM_HEADERS"]) .unwrap_or_default(); @@ -187,12 +214,7 @@ impl OpenAiProvider { let base_path = if let Some(ref explicit_path) = config.base_path { explicit_path.trim_start_matches('/').to_string() } else { - let url_path = url.path().trim_start_matches('/').to_string(); - if url_path.is_empty() || url_path == "v1" || url_path == "v1/" { - "v1/chat/completions".to_string() - } else { - url_path - } + Self::derive_base_path(url.path()) }; let timeout_secs = config.timeout_seconds.unwrap_or(600); @@ -241,6 +263,19 @@ impl OpenAiProvider { }) } + // Derive a base path from the raw URL path + fn derive_base_path(url_path: &str) -> String { + let stripped = url_path.trim_start_matches('/'); + let normalized = stripped.trim_end_matches('/'); + if normalized.is_empty() { + "v1/chat/completions".to_string() + } else if normalized == "v1" || normalized.ends_with("/v1") { + format!("{}/chat/completions", normalized) + } else { + stripped.to_string() + } + } + fn normalize_base_path(base_path: &str) -> String { if let Some(path) = base_path.strip_prefix('/') { format!("/{}", path.trim_end_matches('/')) @@ -260,10 +295,7 @@ impl OpenAiProvider { } fn is_responses_model(model_name: &str) -> bool { - let normalized_model = model_name.to_ascii_lowercase(); - (normalized_model.starts_with("gpt-5") && normalized_model.contains("codex")) - || normalized_model.starts_with("gpt-5.2-pro") - || normalized_model.starts_with("gpt-5.4") + super::utils::is_openai_responses_model(model_name) } fn should_use_responses_api(model_name: &str, base_path: &str) -> bool { @@ -417,6 +449,58 @@ impl ProviderDef for OpenAiProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_configured() -> bool { + let config = crate::config::Config::global(); + // If the host is explicitly set to something non-default, trust the user's + // custom setup (e.g. a local server that doesn't require an API key). + if let Ok(host) = config.get_param::("OPENAI_HOST") { + if host != "https://api.openai.com" { + return true; + } + } + // Standard OpenAI endpoint requires an API key. + config + .get_secret::("OPENAI_API_KEY") + .is_ok() + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + let mut identity = + InventoryIdentityInput::new(OPEN_AI_PROVIDER_NAME, OPEN_AI_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("OPENAI_HOST") + .unwrap_or_else(|_| "https://api.openai.com".to_string()), + ) + .with_public( + "base_path", + config + .get_param::("OPENAI_BASE_PATH") + .unwrap_or_else(|_| OPEN_AI_DEFAULT_BASE_PATH.to_string()), + ); + + if let Ok(organization) = config.get_param::("OPENAI_ORGANIZATION") { + identity = identity.with_public("organization", organization); + } + if let Ok(project) = config.get_param::("OPENAI_PROJECT") { + identity = identity.with_public("project", project); + } + if let Some(api_key) = config_secret_value(config, "OPENAI_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Some(custom_headers) = config_secret_value(config, "OPENAI_CUSTOM_HEADERS") { + identity = identity.with_secret("custom_headers", custom_headers); + } + + Ok(identity) + } } #[async_trait] @@ -495,7 +579,7 @@ impl Provider for OpenAiProvider { &payload_clone, ) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -560,7 +644,7 @@ impl Provider for OpenAiProvider { .api_client .response_post(Some(session_id), &self.base_path, &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -759,59 +843,20 @@ mod tests { } #[test] - fn gpt_5_2_codex_uses_responses_when_base_path_is_default() { - assert!(OpenAiProvider::should_use_responses_api( - "gpt-5.2-codex", - "v1/chat/completions" - )); - } - - #[test] - fn gpt_5_2_pro_uses_responses_when_base_path_is_default() { - assert!(OpenAiProvider::should_use_responses_api( - "gpt-5.2-pro", - "v1/chat/completions" - )); - } - - #[test] - fn gpt_5_2_pro_with_date_uses_responses() { - assert!(OpenAiProvider::should_use_responses_api( - "gpt-5.2-pro-2025-12-11", - "v1/chat/completions" - )); - } - - #[test] - fn explicit_chat_path_forces_chat_completions() { - assert!(!OpenAiProvider::should_use_responses_api( - "gpt-5.2-codex", - "openai/v1/chat/completions" - )); - } - - #[test] - fn gpt_5_4_uses_responses_when_base_path_is_default() { - assert!(OpenAiProvider::should_use_responses_api( - "gpt-5.4", - "v1/chat/completions" - )); - } - - #[test] - fn gpt_5_4_with_date_uses_responses() { - assert!(OpenAiProvider::should_use_responses_api( - "gpt-5.4-2026-03-01", - "v1/chat/completions" - )); - } - - #[test] - fn gpt_4o_does_not_use_responses() { - assert!(!OpenAiProvider::should_use_responses_api( - "gpt-4o", - "v1/chat/completions" - )); + fn responses_api_routing_uses_model_family_unless_path_forces_chat() { + for (model_name, base_path, expected) in [ + ("gpt-5.4", "v1/chat/completions", true), + ("gpt-5.4-xhigh", "v1/chat/completions", true), + ("gpt-5.2-pro-2025-12-11", "v1/chat/completions", true), + ("gpt-4o", "v1/chat/completions", false), + ("gpt-5.2-codex", "openai/v1/chat/completions", false), + ] { + assert_eq!( + OpenAiProvider::should_use_responses_api(model_name, base_path), + expected, + "unexpected routing for {model_name} via {base_path}" + ); + } } #[test] @@ -849,4 +894,54 @@ mod tests { let models_path = OpenAiProvider::map_base_path("/custom/path", "models", "v1/models"); assert_eq!(models_path, "/v1/models"); } + + #[test] + fn derive_base_path_empty_path_gives_default_endpoint() { + assert_eq!(OpenAiProvider::derive_base_path("/"), "v1/chat/completions"); + } + + #[test] + fn derive_base_path_bare_v1_gives_chat_completions() { + assert_eq!( + OpenAiProvider::derive_base_path("/v1"), + "v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_v1_with_trailing_slash() { + assert_eq!( + OpenAiProvider::derive_base_path("/v1/"), + "v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_prefixed_v1_appends_chat_completions() { + assert_eq!( + OpenAiProvider::derive_base_path("/zen/go/v1"), + "zen/go/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_prefixed_v1_with_trailing_slash() { + assert_eq!( + OpenAiProvider::derive_base_path("/zen/go/v1/"), + "zen/go/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_full_chat_completions_url_unchanged() { + assert_eq!( + OpenAiProvider::derive_base_path("/openai/v1/chat/completions"), + "openai/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_non_v1_prefix_unchanged() { + assert_eq!(OpenAiProvider::derive_base_path("/anthropic"), "anthropic"); + } } diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index 225072843848..1028e5079e4c 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -1,7 +1,9 @@ use anyhow::Error; use async_stream::try_stream; use futures::TryStreamExt; -use reqwest::{Response, StatusCode}; +use reqwest::Response; +#[cfg(test)] +use reqwest::StatusCode; use serde_json::Value; use tokio::pin; use tokio_stream::StreamExt; @@ -117,7 +119,7 @@ impl Provider for OpenAiCompatibleProvider { .api_client .response_post(Some(session_id), &completions_path, &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -128,110 +130,12 @@ impl Provider for OpenAiCompatibleProvider { } } -fn check_context_length_exceeded(text: &str) -> bool { - let check_phrases = [ - "too long", - "context length", - "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - "exceed context limit", - "input length", - "max_tokens", - "decrease input length", - "context limit", - "maximum prompt length", - ]; - let text_lower = text.to_lowercase(); - check_phrases - .iter() - .any(|phrase| text_lower.contains(phrase)) -} - -pub fn map_http_error_to_provider_error( - status: StatusCode, - payload: Option, -) -> ProviderError { - let extract_message = || -> String { - payload - .as_ref() - .and_then(|p| { - p.get("error") - .and_then(|e| e.get("message")) - .or_else(|| p.get("message")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| payload.as_ref().map(|p| p.to_string()).unwrap_or_default()) - }; - - let error = match status { - StatusCode::OK => unreachable!("Should not call this function with OK status"), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::Authentication(format!( - "Authentication failed. Status: {}. Response: {}", - status, - extract_message() - )), - StatusCode::NOT_FOUND => { - ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message())) - } - StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted { - details: extract_message(), - top_up_url: None, - }, - StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), - StatusCode::BAD_REQUEST => { - let payload_str = extract_message(); - if check_context_length_exceeded(&payload_str) { - ProviderError::ContextLengthExceeded(payload_str) - } else { - ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) - } - } - StatusCode::TOO_MANY_REQUESTS => ProviderError::RateLimitExceeded { - details: extract_message(), - retry_delay: None, - }, - _ if status.is_server_error() => { - ProviderError::ServerError(format!("Server error ({}): {}", status, extract_message())) - } - _ => ProviderError::RequestFailed(format!( - "Request failed with status {}: {}", - status, - extract_message() - )), - }; - - if !status.is_success() { - tracing::warn!( - "Provider request failed with status: {}. Payload: {:?}. Returning error: {:?}", - status, - payload, - error - ); - } - - error -} - -pub async fn handle_status_openai_compat(response: Response) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - let payload = serde_json::from_str::(&body).ok(); - return Err(map_http_error_to_provider_error(status, payload)); - } - Ok(response) -} +// Re-exported from the dedicated `http_status` module — these helpers are +// format-agnostic and used across all provider families. +pub use super::http_status::{handle_response, handle_status, map_http_error_to_provider_error}; -pub async fn handle_response_openai_compat(response: Response) -> Result { - let response = handle_status_openai_compat(response).await?; - - response.json::().await.map_err(|e| { - ProviderError::RequestFailed(format!("Response body is not valid JSON: {}", e)) - }) -} +// Legacy alias kept for callers that haven't migrated their import path yet. +pub use super::http_status::handle_response as handle_response_openai_compat; pub fn stream_openai_compat( response: Response, diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 1b00d7d9ba69..08b8689b99fd 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; use super::utils::{ImageFormat, RequestLog}; use crate::conversation::message::Message; @@ -291,7 +291,7 @@ impl Provider for OpenRouterProvider { .api_client .response_post(Some(session_id), "api/v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/pi_acp.rs b/crates/goose/src/providers/pi_acp.rs index 5c3eaa72cfe0..b02278f97dcf 100644 --- a/crates/goose/src/providers/pi_acp.rs +++ b/crates/goose/src/providers/pi_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const PI_ACP_PROVIDER_NAME: &str = "pi-acp"; const PI_ACP_DOC_URL: &str = "https://github.com/anthropics/pi"; @@ -36,6 +38,7 @@ impl ProviderDef for PiAcpProvider { "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: pi-acp\n GOOSE_MODEL: current", "Restart goose for changes to take effect", ]) + .with_model_selection_hint("Use the Pi CLI to configure models") } fn from_env( @@ -70,4 +73,16 @@ impl ProviderDef for PiAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + false + } + + fn inventory_identity() -> Result { + acp_inventory_identity(PI_ACP_PROVIDER_NAME, PI_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(PI_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index be2c5a23caaf..6a169fa94656 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -1,4 +1,5 @@ -use super::base::{ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; +use super::base::{ConfigKey, ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; +use super::inventory::InventoryIdentityInput; use crate::config::{DeclarativeProviderConfig, ExtensionConfig}; use crate::model::ModelConfig; use anyhow::Result; @@ -14,12 +15,20 @@ pub type ProviderConstructor = Arc< pub type ProviderCleanup = Arc BoxFuture<'static, Result<()>> + Send + Sync>; +pub type ProviderInventoryIdentityResolver = + Arc Result + Send + Sync>; + +pub type ProviderInventoryConfiguredResolver = Arc bool + Send + Sync>; + #[derive(Clone)] pub struct ProviderEntry { metadata: ProviderMetadata, pub(crate) constructor: ProviderConstructor, + pub(crate) inventory_identity: ProviderInventoryIdentityResolver, + pub(crate) inventory_configured: ProviderInventoryConfiguredResolver, pub(crate) cleanup: Option, provider_type: ProviderType, + supports_inventory_refresh: bool, } impl ProviderEntry { @@ -27,6 +36,22 @@ impl ProviderEntry { &self.metadata } + pub fn provider_type(&self) -> ProviderType { + self.provider_type + } + + pub fn supports_inventory_refresh(&self) -> bool { + self.supports_inventory_refresh + } + + pub fn inventory_identity(&self) -> Result { + (self.inventory_identity)() + } + + pub fn inventory_configured(&self) -> bool { + (self.inventory_configured)() + } + fn normalize_model_config(&self, mut model: ModelConfig) -> ModelConfig { model = model.with_canonical_limits(&self.metadata.name); @@ -92,24 +117,30 @@ impl ProviderRegistry { Ok(Arc::new(provider) as Arc) }) }), + inventory_identity: Arc::new(F::inventory_identity), + inventory_configured: Arc::new(F::inventory_configured), cleanup: None, provider_type: if preferred { ProviderType::Preferred } else { ProviderType::Builtin }, + supports_inventory_refresh: F::supports_inventory_refresh(), }, ); } - pub fn register_with_name( + pub fn register_with_name( &mut self, config: &DeclarativeProviderConfig, provider_type: ProviderType, + supports_inventory_refresh: bool, constructor: F, + inventory_identity: G, ) where P: ProviderDef + 'static, F: Fn(ModelConfig) -> Result + Send + Sync + 'static, + G: Fn() -> Result + Send + Sync + 'static, { let base_metadata = P::metadata(); let description = config @@ -134,28 +165,32 @@ impl ProviderRegistry { }) .collect(); - let mut config_keys = base_metadata.config_keys.clone(); - - if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { - if !config.requires_auth { - config_keys.remove(api_key_index); - } else if !config.api_key_env.is_empty() { - let api_key_required = provider_type == ProviderType::Declarative; - config_keys[api_key_index] = super::base::ConfigKey::new( - &config.api_key_env, - api_key_required, - true, - None, - true, - ); + let mut config_keys = if provider_type == ProviderType::Declarative { + if config.requires_auth && !config.api_key_env.is_empty() { + vec![ConfigKey::new(&config.api_key_env, true, true, None, true)] + } else { + Vec::new() } - } + } else { + let mut config_keys = base_metadata.config_keys.clone(); + + if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { + if !config.requires_auth { + config_keys.remove(api_key_index); + } else if !config.api_key_env.is_empty() { + config_keys[api_key_index] = + ConfigKey::new(&config.api_key_env, false, true, None, true); + } + } + + config_keys + }; if let Some(ref env_vars) = config.env_vars { for ev in env_vars { // Default primary to `required` so required fields show prominently in the UI let primary = ev.primary.unwrap_or(ev.required); - config_keys.push(super::base::ConfigKey::new( + config_keys.push(ConfigKey::new( &ev.name, ev.required, ev.secret, @@ -171,10 +206,15 @@ impl ProviderRegistry { description, default_model, known_models, - model_doc_link: base_metadata.model_doc_link, + model_doc_link: config + .model_doc_link + .clone() + .unwrap_or(base_metadata.model_doc_link), config_keys, - setup_steps: vec![], + setup_steps: config.setup_steps.clone(), + model_selection_hint: None, }; + let inventory_config_keys = custom_metadata.config_keys.clone(); self.entries.insert( config.name.clone(), @@ -187,8 +227,16 @@ impl ProviderRegistry { Ok(Arc::new(provider) as Arc) }) }), + inventory_identity: Arc::new(inventory_identity), + inventory_configured: Arc::new(move || { + super::inventory::default_inventory_configured( + &inventory_config_keys, + crate::config::Config::global(), + ) + }), cleanup: None, provider_type, + supports_inventory_refresh, }, ); } diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 98aefefd0d08..810246168f64 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -2,7 +2,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + handle_response_openai_compat, handle_status, map_http_error_to_provider_error, stream_openai_compat, }; use super::retry::ProviderRetry; @@ -155,7 +155,7 @@ impl Provider for TetrateProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - let resp = handle_status_openai_compat(resp) + let resp = handle_status(resp) .await .map_err(Self::enrich_credits_error)?; diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs index 61a2f58fb162..3a99b88e6041 100644 --- a/crates/goose/src/providers/utils.rs +++ b/crates/goose/src/providers/utils.rs @@ -193,26 +193,48 @@ pub async fn handle_response_google_compat(response: Response) -> Result (String, Option) { - let is_reasoning_model = model_name.starts_with("o1") - || model_name.starts_with("o2") - || model_name.starts_with("o3") - || model_name.starts_with("o4") - || model_name.starts_with("gpt-5"); +/// True when the model should use the OpenAI Responses API. +/// +/// The Responses API is backwards-compatible with all OpenAI reasoning +/// models, so every `o`-series (`o1`, `o3`, `o4`, …) and `gpt-5` variant +/// routes here. The matcher intentionally scans the full model identifier so +/// hosted aliases like `databricks-gpt-5.4`, `goose-o3-mini`, or +/// `headless-goose-o3-mini` work without provider-specific normalization. +pub fn is_openai_responses_model(model_name: &str) -> bool { + static RE: OnceLock = OnceLock::new(); + let re = + RE.get_or_init(|| Regex::new(r"(?i)(?:^|[-/])(?:o\d+(?:$|-)|gpt-5(?:$|[-.]))").unwrap()); + re.is_match(model_name) +} - if !is_reasoning_model { +/// Extract an explicit reasoning-effort suffix from a model name. +/// +/// Returns `(base_model_name, Some(effort))` when the user appended a +/// recognised suffix like `-high` or `-xhigh`, e.g. `gpt-5.4-high` → +/// `("gpt-5.4", Some("high"))`. +/// +/// When no suffix is present the effort is `None` — callers should omit +/// the `reasoning` field entirely so the API applies its own per-model +/// default. This avoids hard-coding a default that may be invalid for +/// certain models (e.g. `gpt-5-pro` only accepts `high`; older o-series +/// models reject `none` and `xhigh`). +pub fn extract_reasoning_effort(model_name: &str) -> (String, Option) { + if !is_openai_responses_model(model_name) { return (model_name.to_string(), None); } - let parts: Vec<&str> = model_name.split('-').collect(); - let last_part = parts.last().unwrap(); - match *last_part { - "low" | "medium" | "high" => { - let base_name = parts[..parts.len() - 1].join("-"); - (base_name, Some(last_part.to_string())) - } - _ => (model_name.to_string(), Some("medium".to_string())), + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r"(?i)^(?P.+)-(?Pnone|low|medium|high|xhigh)$").unwrap() + }); + + if let Some(captures) = re.captures(model_name) { + let base = captures["base"].to_string(); + let effort = captures["effort"].to_ascii_lowercase(); + return (base, Some(effort)); } + + (model_name.to_string(), None) } pub fn sanitize_function_name(name: &str) -> String { @@ -870,4 +892,65 @@ mod tests { Some(Duration::from_secs(42)) ); } + + #[test] + fn test_is_openai_responses_model_matches_o_and_gpt5_families() { + for model in [ + "o3", + "o3-mini", + "o4-mini", + "gpt-5", + "gpt-5-pro", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-4", + "gpt-5-2-pro", + "databricks-gpt-5.4", + "goose-gpt-5.4-high", + "headless-goose-o3-mini", + ] { + assert!(is_openai_responses_model(model), "{model} should match"); + } + } + + #[test] + fn test_is_openai_responses_model_rejects_other_families() { + for model in [ + "gpt-4o", + "claude-sonnet-4", + "databricks-claude-sonnet-4", + "llama-3-70b", + ] { + assert!( + !is_openai_responses_model(model), + "{model} should not match" + ); + } + } + + #[test] + fn test_extract_reasoning_effort_for_responses_models() { + for (model, expected_name, expected_effort) in [ + ("o3-none", "o3", Some("none")), + ("o3-xhigh", "o3", Some("xhigh")), + ("gpt-5-low", "gpt-5", Some("low")), + ("gpt-5.4", "gpt-5.4", None), + ( + "databricks-gpt-5.4-high", + "databricks-gpt-5.4", + Some("high"), + ), + ("databricks-o3-low", "databricks-o3", Some("low")), + ("goose-gpt-5-high", "goose-gpt-5", Some("high")), + ("gpt-4o", "gpt-4o", None), + ] { + let (name, effort) = extract_reasoning_effort(model); + assert_eq!(name, expected_name, "unexpected base model for {model}"); + assert_eq!( + effort.as_deref(), + expected_effort, + "unexpected effort for {model}" + ); + } + } } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index bea5ec97170c..5c6559be1481 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock}; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 10; +pub const CURRENT_SCHEMA_VERSION: i32 = 11; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -717,6 +717,8 @@ impl SessionStorage { .execute(pool) .await?; + crate::providers::inventory::create_tables(pool).await?; + Ok(()) } @@ -1060,6 +1062,9 @@ impl SessionStorage { .execute(&mut **tx) .await?; } + 11 => { + crate::providers::inventory::create_tables_in_tx(tx).await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } diff --git a/crates/goose/src/session/thread_manager.rs b/crates/goose/src/session/thread_manager.rs index 3a96cc69ff43..086381887159 100644 --- a/crates/goose/src/session/thread_manager.rs +++ b/crates/goose/src/session/thread_manager.rs @@ -30,8 +30,8 @@ pub struct ThreadMetadata { pub project_id: Option, #[serde(default)] pub provider_id: Option, - #[serde(default)] - pub model_name: Option, + #[serde(default, alias = "model_name")] + pub model_id: Option, #[serde(default)] pub mode: Option, #[serde(flatten)] diff --git a/crates/goose/src/agents/builtin_skills/mod.rs b/crates/goose/src/skills/builtin.rs similarity index 70% rename from crates/goose/src/agents/builtin_skills/mod.rs rename to crates/goose/src/skills/builtin.rs index 039e7680448d..daaec140e29f 100644 --- a/crates/goose/src/agents/builtin_skills/mod.rs +++ b/crates/goose/src/skills/builtin.rs @@ -1,7 +1,6 @@ use include_dir::{include_dir, Dir}; -static BUILTIN_SKILLS_DIR: Dir = - include_dir!("$CARGO_MANIFEST_DIR/src/agents/builtin_skills/skills"); +static BUILTIN_SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/skills/builtins"); pub fn get_all() -> Vec<&'static str> { BUILTIN_SKILLS_DIR diff --git a/crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md b/crates/goose/src/skills/builtins/goose_doc_guide.md similarity index 100% rename from crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md rename to crates/goose/src/skills/builtins/goose_doc_guide.md diff --git a/crates/goose/src/skills/client.rs b/crates/goose/src/skills/client.rs new file mode 100644 index 000000000000..e1104da9a141 --- /dev/null +++ b/crates/goose/src/skills/client.rs @@ -0,0 +1,1420 @@ +use super::discover_skills; +use super::mcp_client::McpSkillEntry; +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::extension_manager::ExtensionManager; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use crate::agents::ToolCallContext; +use async_trait::async_trait; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, + ResourceContents, ServerCapabilities, ServerNotification, Tool, +}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, Weak}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +/// How long a cached snapshot of installed FS skill names stays valid. The +/// cache backs FS-vs-MCP collision detection in `get_dynamic_instructions`, +/// which runs every reply; without the cache we'd walk up to seven skill +/// directories per turn once any MCP skill is cached. Ten seconds is plenty +/// short for newly-added local skills to show up on the next collision +/// check. +const FS_NAMES_TTL: Duration = Duration::from_secs(10); + +pub static EXTENSION_NAME: &str = "skills"; + +pub struct SkillsClient { + info: InitializeResult, + working_dir: PathBuf, + /// Weak reference to the extension manager so we can, per turn, read + /// the MCP-served skills cache populated at server connect time and + /// dispatch `resources/read` when `load_skill` hits an MCP entry. + /// `None` in session-less contexts (tests, bootstrap). + extension_manager: Option>, + /// TTL-cached snapshot of installed FS skill names. Read on every reply + /// to drive FS-vs-MCP collision prefixing; we recompute at most once + /// per `FS_NAMES_TTL` so the per-turn cost is amortized. + fs_names_cache: Mutex, +} + +#[derive(Default)] +struct FsNamesCache { + refreshed_at: Option, + names: HashSet, +} + +impl SkillsClient { + pub fn new(context: PlatformExtensionContext) -> anyhow::Result { + let working_dir = context + .session + .as_ref() + .map(|s| s.working_dir.clone()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + + let mut instructions = String::new(); + if context.session.is_some() { + let sources = discover_skills(Some(&working_dir)); + let mut skills: Vec<&SourceEntry> = sources + .iter() + .filter(|s| { + s.source_type == SourceType::Skill || s.source_type == SourceType::BuiltinSkill + }) + .collect(); + skills.sort_by(|a, b| (&a.name, &a.directory).cmp(&(&b.name, &b.directory))); + + if !skills.is_empty() { + instructions.push_str( + "\n\nYou have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:", + ); + for skill in &skills { + instructions.push_str(&format!("\n• {} - {}", skill.name, skill.description)); + } + } + } + + let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Skills")) + .with_instructions(instructions); + + Ok(Self { + info, + working_dir, + extension_manager: context.extension_manager, + fs_names_cache: Mutex::new(FsNamesCache::default()), + }) + } + + /// Returns the current set of MCP skills visible via the extension + /// manager's cache, or empty if no manager is attached. + async fn mcp_skills(&self) -> Vec { + match self.extension_manager.as_ref().and_then(|w| w.upgrade()) { + Some(mgr) => mgr.aggregated_mcp_skills().await, + None => Vec::new(), + } + } + + /// Cached wrapper around `fs_skill_names`. Rescans the FS when the + /// previous snapshot is older than `FS_NAMES_TTL`, or on first call. + /// Never holds the mutex across the FS walk — we drop the guard, do + /// the blocking scan, then re-acquire to write. The scan can race + /// with a concurrent call but the result is equivalent (both computes + /// produce the same set for identical FS state, and the last write + /// wins with a fresh timestamp). + fn fs_skill_names_cached(&self) -> HashSet { + { + let cache = self.fs_names_cache.lock().expect("fs_names_cache poisoned"); + if let Some(ts) = cache.refreshed_at { + if ts.elapsed() < FS_NAMES_TTL { + return cache.names.clone(); + } + } + } + + let fresh = fs_skill_names(&self.working_dir); + + let mut cache = self.fs_names_cache.lock().expect("fs_names_cache poisoned"); + cache.refreshed_at = Some(Instant::now()); + cache.names = fresh.clone(); + fresh + } +} + +/// Rebuilds the list of FS skill names currently installed. Used to detect +/// FS-vs-MCP name collisions (FS wins — the MCP entry is rendered with a +/// `__` prefix). +fn fs_skill_names(working_dir: &Path) -> HashSet { + discover_skills(Some(working_dir)) + .into_iter() + .filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill)) + .map(|s| s.name) + .collect() +} + +/// Renders the MCP skills section of the system prompt. Collisions with +/// FS skill names use the `__` form so the model can still +/// address the MCP entry unambiguously via `load_skill`. Empty output +/// when no MCP skills are available — caller drops the section entirely. +fn format_mcp_skills_section(fs_names: &HashSet, mcp: &[McpSkillEntry]) -> String { + if mcp.is_empty() { + return String::new(); + } + + // Bucket by bare name to detect MCP-vs-MCP collisions separately from + // FS collisions. + let mut counts: std::collections::HashMap<&str, usize> = + std::collections::HashMap::new(); + for entry in mcp { + *counts.entry(entry.name.as_str()).or_insert(0) += 1; + } + + let mut sorted: Vec<&McpSkillEntry> = mcp.iter().collect(); + sorted.sort_by(|a, b| (a.name.as_str(), a.server.as_str()).cmp(&(b.name.as_str(), b.server.as_str()))); + + let mut out = String::from( + "\n\nYou also have these skills from connected MCP servers. Load them via load_skill by name; if a collision is shown in __ form, use that exact form:", + ); + for entry in sorted { + let needs_prefix = + fs_names.contains(&entry.name) || counts[entry.name.as_str()] > 1; + let display_name = if needs_prefix { + format!("{}__{}", entry.server, entry.name) + } else { + entry.name.clone() + }; + // URI intentionally omitted: the model addresses MCP skills by + // name via `load_skill`, and including full URIs for every entry + // bloats every turn's system prompt on servers with many skills. + out.push_str(&format!( + "\n• {} ({}) - {}", + display_name, entry.server, entry.description + )); + } + out +} + +/// Extracts the first text content from a `ReadResourceResult`. Returns +/// `None` if the result contains only blob contents (binary). Logs a +/// warning if the server returned more than one text entry — the SEP +/// expects SKILL.md to arrive as a single document, and a multi-entry +/// response likely means the server is splitting content in a way the +/// host won't reassemble. +fn first_text_content( + result: rmcp::model::ReadResourceResult, + server: &str, + uri: &str, +) -> Option { + let mut text_count = 0usize; + let mut first: Option = None; + for c in result.contents { + if let ResourceContents::TextResourceContents { text, .. } = c { + text_count += 1; + if first.is_none() { + first = Some(text); + } + } + } + if text_count > 1 { + warn!( + server, + uri, + text_count, + "read_resource returned multiple text contents; only the first was used" + ); + } + first +} + +/// Normalizes a supporting-file relative reference before composing it with +/// a server's `base_uri`. Rejects inputs that could escape the skill +/// directory — `..` segments or a leading `/`. Backslashes are folded to +/// forward slashes so Windows-style paths from the model don't slip past +/// the `..` check. Returns `None` if the input is unsafe to compose. +fn sanitize_relative_ref(raw: &str) -> Option { + let normalized = raw.replace('\\', "/"); + if normalized.starts_with('/') { + return None; + } + if normalized.split('/').any(|segment| segment == "..") { + return None; + } + Some(normalized) +} + +/// Finds an MCP skill entry by name, accepting either the bare name or the +/// `__` collision form. Literal match wins so a server can +/// legitimately publish a skill whose name contains `__` without being +/// hijacked by a coincidental server/skill pair on the other side of the +/// split. +fn find_mcp_by_name<'a>(mcp: &'a [McpSkillEntry], query: &str) -> Option<&'a McpSkillEntry> { + if let Some(hit) = mcp.iter().find(|e| e.name == query) { + return Some(hit); + } + if let Some((server_prefix, bare_name)) = query.split_once("__") { + return mcp + .iter() + .find(|e| e.server == server_prefix && e.name == bare_name); + } + None +} + +/// Enumerates supporting resources for an MCP skill by filtering the +/// server's `resources/list` response down to URIs under `entry.base_uri` +/// (excluding the SKILL.md itself). Returns `(relative_ref, uri)` pairs +/// suitable for the "Supporting Files" hint block. Best-effort: a server +/// that doesn't support `resources/list`, errors out, or returns nothing +/// relevant yields an empty vec and no section is rendered. Mirrors the FS +/// load_skill behaviour — it only surfaces *pointers*, never resource +/// content. +async fn enumerate_mcp_supporting_resources( + mgr: &ExtensionManager, + session_id: &str, + entry: &McpSkillEntry, + cancel: CancellationToken, +) -> Vec<(String, String)> { + let list = match mgr + .list_resources_for_server(session_id, &entry.server, cancel) + .await + { + Ok(list) => list, + Err(e) => { + debug!( + server = %entry.server, + skill = %entry.name, + error = %e.message, + "list_resources failed; rendering MCP skill without supporting-files section", + ); + return Vec::new(); + } + }; + + let mut out = Vec::new(); + for r in list.resources { + let uri = r.uri.clone(); + if uri == entry.uri { + continue; + } + let Some(rel) = uri.strip_prefix(&entry.base_uri) else { + continue; + }; + if rel.is_empty() { + continue; + } + out.push((rel.to_string(), uri)); + } + out +} + +/// Reads a resource from the owning MCP server and wraps it in the same +/// "Loaded Skill" framing used for filesystem skills, so the model sees a +/// consistent shape regardless of source. When the resource is the skill's +/// own SKILL.md (i.e. `uri == entry.uri`), also appends a Supporting Files +/// hint section built from the server's `resources/list` response — same +/// shape as the FS path, surfacing pointers only, never content. +async fn read_mcp_and_frame( + mgr: &ExtensionManager, + session_id: &str, + entry: &McpSkillEntry, + uri: &str, + cancel: CancellationToken, +) -> CallToolResult { + let is_skill_md = uri == entry.uri; + match mgr.read_resource(session_id, uri, &entry.server, cancel.clone()).await { + Ok(result) => match first_text_content(result, &entry.server, uri) { + Some(body) => { + let mut output = format!( + "# Loaded Skill: {} (mcp skill from {})\n\n{}\n", + entry.name, entry.server, body + ); + + if is_skill_md { + let supporting = + enumerate_mcp_supporting_resources(mgr, session_id, entry, cancel).await; + if !supporting.is_empty() { + output.push_str(&format!( + "\n## Supporting Files\n\nSkill base: {}\n\n", + entry.base_uri + )); + for (rel, _uri) in &supporting { + output.push_str(&format!( + "- {} → load_skill(name: \"{}/{}\")\n", + rel, entry.name, rel + )); + } + } + } + + output.push_str("\n---\nThis knowledge is now available in your context."); + CallToolResult::success(vec![Content::text(output)]) + } + None => CallToolResult::error(vec![Content::text(format!( + "Resource '{}' from '{}' had no text content.", + uri, entry.server + ))]), + }, + Err(e) => CallToolResult::error(vec![Content::text(format!( + "Failed to read '{}' from '{}': {}", + uri, entry.server, e.message + ))]), + } +} + +#[async_trait] +impl McpClientTrait for SkillsClient { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + let load_skill_schema = serde_json::json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Name of the skill to load. Use \"skill-name/path\" to load a supporting file. For MCP skills with a name collision, use the \"__\" form shown in your system instructions. Do NOT pass a URI here — use the read_resource tool (on the extensionmanager) if you only have a URI." + } + } + }); + + let load_skill = Tool::new( + "load_skill", + "Load a skill's full content into your context so you can follow its instructions.\n\n\ + Skills are listed in your system instructions (both local skills and skills from connected MCP servers). When you need to use one, load it first to get the detailed instructions.\n\n\ + Examples:\n\ + - load_skill(name: \"gdrive\") → Loads the gdrive skill instructions\n\ + - load_skill(name: \"my-skill/template.md\") → Loads a supporting file\n\ + - load_skill(name: \"github__pull-requests\") → Disambiguates a collision between two servers\n\n\ + Use read_resource (from the extensionmanager) if you only have a raw URI. Do NOT use read_text_file, text_editor, or shell on skill URIs — those operate on filesystem paths." + .to_string(), + load_skill_schema.as_object().unwrap().clone(), + ); + + Ok(ListToolsResult { + tools: vec![load_skill], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + ctx: &ToolCallContext, + name: &str, + arguments: Option, + cancellation_token: CancellationToken, + ) -> Result { + if name != "load_skill" { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Unknown tool: {}", + name + ))])); + } + + let skill_name = arguments + .as_ref() + .and_then(|args| args.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if skill_name.is_empty() { + return Ok(CallToolResult::error(vec![Content::text( + "Missing required parameter: name", + )])); + } + + // Reject raw URIs — they go through `read_resource` (a separate + // tool) rather than `load_skill`. Shares `looks_like_uri` with + // `developer::edit::reject_uri_path` so the two guardrails can't + // drift apart on scheme shape. + if crate::agents::platform_extensions::looks_like_uri(skill_name) { + return Ok(CallToolResult::error(vec![Content::text(format!( + "'{}' looks like a URI. Use the read_resource tool instead (it takes a server name and a uri). load_skill takes a skill name or /.", + skill_name + ))])); + } + + let skills = discover_skills(Some(&self.working_dir)); + + if let Some(skill) = skills.iter().find(|s| s.name == skill_name) { + let mut output = format!( + "# Loaded Skill: {} ({})\n\n{}\n", + skill.name, + skill.source_type, + skill.to_load_text() + ); + + if !skill.supporting_files.is_empty() { + let skill_dir = Path::new(&skill.directory); + output.push_str(&format!( + "\n## Supporting Files\n\nSkill directory: {}\n\n", + skill.directory + )); + for file in &skill.supporting_files { + if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) { + let rel_str = relative.to_string_lossy().replace('\\', "/"); + output.push_str(&format!( + "- {} → load_skill(name: \"{}/{}\")\n", + rel_str, skill.name, rel_str + )); + } + } + } + + output.push_str("\n---\nThis knowledge is now available in your context."); + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + + if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') { + let relative_path = raw_relative_path.replace('\\', "/"); + if let Some(skill) = skills.iter().find(|s| { + s.name == parent_skill_name + && matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill) + }) { + let skill_dir = PathBuf::from(&skill.directory); + let canonical_skill_dir = skill_dir + .canonicalize() + .unwrap_or_else(|_| skill_dir.clone()); + + for file_path in &skill.supporting_files { + let file_path_buf = Path::new(file_path); + let Ok(rel) = file_path_buf.strip_prefix(&skill_dir) else { + continue; + }; + if rel.to_string_lossy().replace('\\', "/") != relative_path { + continue; + } + + return Ok(match file_path_buf.canonicalize() { + Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => { + match std::fs::read_to_string(&canonical) { + Ok(content) => { + CallToolResult::success(vec![Content::text(format!( + "# Loaded: {}\n\n{}\n\n---\nFile loaded into context.", + skill_name, content + ))]) + } + Err(e) => CallToolResult::error(vec![Content::text(format!( + "Failed to read '{}': {}", + skill_name, e + ))]), + } + } + Ok(_) => CallToolResult::error(vec![Content::text(format!( + "Refusing to load '{}': resolves outside the skill directory", + skill_name + ))]), + Err(e) => CallToolResult::error(vec![Content::text(format!( + "Failed to resolve '{}': {}", + skill_name, e + ))]), + }); + } + + let available: Vec = skill + .supporting_files + .iter() + .filter_map(|f| { + Path::new(f) + .strip_prefix(&skill_dir) + .ok() + .map(|r| r.to_string_lossy().replace('\\', "/")) + }) + .take(10) + .collect(); + + return Ok(if available.is_empty() { + CallToolResult::error(vec![Content::text(format!( + "Skill '{}' has no supporting files.", + skill.name + ))]) + } else { + CallToolResult::error(vec![Content::text(format!( + "File '{}' not found. Available: {}", + skill_name, + available.join(", ") + ))]) + }); + } + } + + // MCP skill routing. Read the cache populated at extension-connect + // time. `__` disambiguation is supported alongside + // bare names. + let mcp_skills = self.mcp_skills().await; + let mgr = self.extension_manager.as_ref().and_then(|w| w.upgrade()); + + if let Some(entry) = find_mcp_by_name(&mcp_skills, skill_name) { + if let Some(ref mgr) = mgr { + return Ok(read_mcp_and_frame( + mgr.as_ref(), + &ctx.session_id, + entry, + &entry.uri, + cancellation_token.clone(), + ) + .await); + } + } + + if let Some((parent, raw_rel)) = skill_name.split_once('/') { + if let Some(entry) = find_mcp_by_name(&mcp_skills, parent) { + if let Some(ref mgr) = mgr { + let Some(rel) = sanitize_relative_ref(raw_rel) else { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Refusing to load '{}': relative path must not contain '..' or start with '/'.", + skill_name + ))])); + }; + let composed = format!("{}{}", entry.base_uri, rel); + return Ok(read_mcp_and_frame( + mgr.as_ref(), + &ctx.session_id, + entry, + &composed, + cancellation_token.clone(), + ) + .await); + } + } + } + + let mut candidates: Vec<&str> = skills + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&skill_name.to_lowercase()) + || skill_name.to_lowercase().contains(&s.name.to_lowercase()) + }) + .map(|s| s.name.as_str()) + .collect(); + candidates.extend(mcp_skills.iter().filter_map(|e| { + if e.name.to_lowercase().contains(&skill_name.to_lowercase()) + || skill_name.to_lowercase().contains(&e.name.to_lowercase()) + { + Some(e.name.as_str()) + } else { + None + } + })); + candidates.sort(); + candidates.dedup(); + candidates.truncate(3); + + Ok(if candidates.is_empty() { + CallToolResult::error(vec![Content::text(format!( + "Skill '{}' not found.", + skill_name + ))]) + } else { + CallToolResult::error(vec![Content::text(format!( + "Skill '{}' not found. Did you mean: {}?", + skill_name, + candidates.join(", ") + ))]) + }) + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn subscribe(&self) -> mpsc::Receiver { + let (_tx, rx) = mpsc::channel(1); + rx + } + + async fn get_dynamic_instructions(&self, _session_id: &str) -> Option { + let mcp = self.mcp_skills().await; + if mcp.is_empty() { + return None; + } + let fs_names = self.fs_skill_names_cached(); + let section = format_mcp_skills_section(&fs_names, &mcp); + if section.is_empty() { + None + } else { + Some(section) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Arc; + use tempfile::TempDir; + + #[tokio::test] + async fn test_load_skill_from_filesystem() { + let temp_dir = TempDir::new().unwrap(); + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A test skill\n---\nDo the thing.", + ) + .unwrap(); + + let session = std::sync::Arc::new(crate::session::Session { + working_dir: temp_dir.path().to_path_buf(), + ..crate::session::Session::default() + }); + let client = SkillsClient::new(PlatformExtensionContext { + extension_manager: None, + session_manager: Arc::new(crate::session::SessionManager::instance()), + session: Some(session), + }) + .unwrap(); + + let ctx = ToolCallContext::new("test".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "my-skill"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + let text = match &result.content[0].raw { + rmcp::model::RawContent::Text(t) => &t.text, + _ => panic!("expected text"), + }; + assert!(text.contains("my-skill")); + assert!(text.contains("Do the thing")); + } + + #[tokio::test] + async fn test_load_skill_not_found_returns_error() { + let client = SkillsClient::new(PlatformExtensionContext { + extension_manager: None, + session_manager: Arc::new(crate::session::SessionManager::instance()), + session: None, + }) + .unwrap(); + + let ctx = ToolCallContext::new("test".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "nonexistent"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(result.is_error.unwrap_or(false)); + } + + // ---------- MCP skill routing tests ---------- + // + // These exercise the end-to-end path: register a FakeMcp server in an + // ExtensionManager (which populates the mcp_skills cache at connect + // time), wire a SkillsClient to a Weak of that manager, and call + // load_skill. + + use crate::agents::extension::ExtensionConfig; + use crate::agents::extension_manager::ExtensionManager; + use async_trait::async_trait; + use rmcp::model::{ + ExtensionCapabilities, ListResourcesResult, ReadResourceResult, ServerNotification, + }; + use std::collections::HashMap; + + struct FakeMcp { + info: InitializeResult, + resources: HashMap, + } + + impl FakeMcp { + fn new(resources: HashMap) -> Self { + let mut caps = ExtensionCapabilities::new(); + caps.insert( + super::super::mcp_client::SKILLS_EXTENSION_ID.to_string(), + JsonObject::new(), + ); + let info = InitializeResult::new( + ServerCapabilities::builder() + .enable_resources() + .enable_extensions_with(caps) + .build(), + ); + Self { info, resources } + } + } + + #[async_trait] + impl McpClientTrait for FakeMcp { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _ctx: &ToolCallContext, + _name: &str, + _arguments: Option, + _cancel_token: CancellationToken, + ) -> Result { + unreachable!("FakeMcp has no tools") + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn list_resources( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + // Return every resource the test registered, excluding the index + // itself — mirrors what a real server would expose via + // `resources/list`, and lets MCP supporting-files enumeration + // find entries under each skill's base_uri. + let resources = self + .resources + .keys() + .filter(|uri| uri.as_str() != super::super::mcp_client::INDEX_URI) + .map(|uri| { + rmcp::model::Annotated::new( + rmcp::model::RawResource::new(uri.as_str(), uri.as_str()), + None, + ) + }) + .collect(); + Ok(ListResourcesResult { + resources, + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + _session_id: &str, + uri: &str, + _cancel_token: CancellationToken, + ) -> Result { + match self.resources.get(uri) { + Some(text) => Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: uri.to_string(), + mime_type: None, + text: text.clone(), + meta: None, + }, + ])), + None => Err(Error::TransportClosed), + } + } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } + } + + async fn setup_client_with_fake( + server_name: &str, + resources: HashMap, + working_dir: PathBuf, + ) -> (SkillsClient, Arc, TempDir) { + let tmp = TempDir::new().unwrap(); + let mgr = Arc::new(ExtensionManager::new_without_provider( + tmp.path().to_path_buf(), + )); + + let fake: std::sync::Arc = std::sync::Arc::new(FakeMcp::new(resources)); + mgr.add_client( + server_name.to_string(), + ExtensionConfig::Builtin { + name: server_name.to_string(), + display_name: Some(server_name.to_string()), + description: "fake mcp".to_string(), + timeout: None, + bundled: None, + available_tools: vec![], + }, + fake, + None, + None, + Some("s"), + ) + .await; + + let session = Arc::new(crate::session::Session { + working_dir: working_dir.clone(), + ..crate::session::Session::default() + }); + + let client = SkillsClient::new(PlatformExtensionContext { + extension_manager: Some(Arc::downgrade(&mgr)), + session_manager: Arc::new(crate::session::SessionManager::instance()), + session: Some(session), + }) + .unwrap(); + + (client, mgr, tmp) + } + + fn index_json(entries: &str) -> String { + format!( + r#"{{"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{}]}}"#, + entries + ) + } + + fn text_of(r: &CallToolResult) -> String { + match &r.content[0].raw { + rmcp::model::RawContent::Text(t) => t.text.clone(), + _ => panic!("expected text content"), + } + } + + #[tokio::test] + async fn test_load_mcp_skill_basic() { + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"git-workflow","type":"skill-md","description":"Git","url":"skill://git-workflow/SKILL.md"}"#, + ), + ); + resources.insert( + "skill://git-workflow/SKILL.md".to_string(), + "---\nname: git-workflow\ndescription: Git\n---\nGit body text.".to_string(), + ); + + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("gh", resources, tmp.path().to_path_buf()).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "git-workflow"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + let body = text_of(&result); + assert!(body.contains("Git body text"), "got: {}", body); + assert!(body.contains("mcp skill from gh"), "got: {}", body); + } + + #[tokio::test] + async fn test_load_mcp_skill_non_skill_scheme() { + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"pull-requests","type":"skill-md","description":"PRs","url":"github://o/r/skills/pull-requests/SKILL.md"}"#, + ), + ); + resources.insert( + "github://o/r/skills/pull-requests/SKILL.md".to_string(), + "PR review workflow body.".to_string(), + ); + + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("github", resources, tmp.path().to_path_buf()).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "pull-requests"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + assert!(text_of(&result).contains("PR review workflow body")); + } + + #[tokio::test] + async fn test_load_mcp_supporting_file() { + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"docs","type":"skill-md","description":"","url":"skill://docs/SKILL.md"}"#, + ), + ); + resources.insert( + "skill://docs/references/GUIDE.md".to_string(), + "Guide body.".to_string(), + ); + + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("srv", resources, tmp.path().to_path_buf()).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "docs/references/GUIDE.md"})) + .unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + assert!(text_of(&result).contains("Guide body")); + } + + #[tokio::test] + async fn test_load_mcp_supporting_file_rejects_parent_traversal() { + // A model (or a hijacked server index) that asks for `docs/../other` + // or `docs//absolute` must not be composed into the server URI — it + // could escape the skill directory on a filesystem-backed resolver. + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"docs","type":"skill-md","description":"","url":"skill://docs/SKILL.md"}"#, + ), + ); + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("srv", resources, tmp.path().to_path_buf()).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + for bad in ["docs/../secrets/SKILL.md", "docs//etc/passwd"] { + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": bad})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + assert!( + result.is_error.unwrap_or(false), + "expected rejection for {bad}, got: {:?}", + text_of(&result) + ); + let body = text_of(&result); + assert!( + body.contains("Refusing to load") || body.contains(".."), + "unexpected rejection message for {bad}: {body}" + ); + } + } + + #[tokio::test] + async fn test_load_skill_uri_input_redirects_to_read_resource() { + // load_skill is name-only; passing a URI returns an instructive + // error pointing the model at read_resource. + let tmp = TempDir::new().unwrap(); + let (client, _mgr, _tmp_guard) = setup_client_with_fake( + "srv", + HashMap::new(), + tmp.path().to_path_buf(), + ) + .await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = serde_json::from_value( + serde_json::json!({"name": "skill://unknown/SKILL.md"}), + ) + .unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(result.is_error.unwrap_or(false)); + let body = text_of(&result); + assert!(body.contains("read_resource"), "got: {}", body); + } + + #[tokio::test] + async fn test_get_extensions_info_roundtrip_does_not_deadlock() { + // Regression guard: `ExtensionManager::get_extensions_info` iterates + // registered extensions and invokes `get_dynamic_instructions` on + // each. `SkillsClient::get_dynamic_instructions` in turn calls + // `mgr.aggregated_mcp_skills()`, which re-acquires the same + // `extensions` mutex. If any future edit inlines that call inside + // the iteration lock scope, this test will hang (tokio::time::timeout + // turns that into a clean failure). + let tmp = TempDir::new().unwrap(); + let working_dir = tmp.path().to_path_buf(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"alpha","type":"skill-md","description":"A","url":"skill://alpha/SKILL.md"}"#, + ), + ); + + let mgr = Arc::new(ExtensionManager::new_without_provider(working_dir.clone())); + let fake: Arc = Arc::new(FakeMcp::new(resources)); + mgr.add_client( + "srv".to_string(), + ExtensionConfig::Builtin { + name: "srv".to_string(), + display_name: Some("srv".to_string()), + description: "fake mcp".to_string(), + timeout: None, + bundled: None, + available_tools: vec![], + }, + fake, + None, + None, + Some("s"), + ) + .await; + + // Register a SkillsClient into the manager so `get_extensions_info` + // will call its `get_dynamic_instructions` during iteration. + let session = Arc::new(crate::session::Session { + working_dir: working_dir.clone(), + ..crate::session::Session::default() + }); + let skills_client: Arc = Arc::new( + SkillsClient::new(PlatformExtensionContext { + extension_manager: Some(Arc::downgrade(&mgr)), + session_manager: Arc::new(crate::session::SessionManager::instance()), + session: Some(session), + }) + .unwrap(), + ); + mgr.add_client( + EXTENSION_NAME.to_string(), + ExtensionConfig::Builtin { + name: EXTENSION_NAME.to_string(), + display_name: Some("Skills".to_string()), + description: "skills".to_string(), + timeout: None, + bundled: None, + available_tools: vec![], + }, + skills_client, + None, + None, + Some("s"), + ) + .await; + + // Bounded wait — a self-deadlock would hang forever. + let infos = tokio::time::timeout( + std::time::Duration::from_secs(5), + mgr.get_extensions_info("s", &working_dir), + ) + .await + .expect("get_extensions_info must not deadlock on the extensions lock"); + + let skills_info = infos + .iter() + .find(|i| i.name == EXTENSION_NAME) + .expect("skills extension should appear in get_extensions_info output"); + assert!( + skills_info.instructions.contains("alpha"), + "dynamic instructions should include the MCP skill; got: {}", + skills_info.instructions + ); + } + + #[tokio::test] + async fn test_dynamic_instructions_include_mcp_skills() { + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"alpha","type":"skill-md","description":"A","url":"skill://alpha/SKILL.md"}"#, + ), + ); + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("srv", resources, tmp.path().to_path_buf()).await; + + let out = client + .get_dynamic_instructions("s") + .await + .expect("should have dynamic output"); + assert!(out.contains("alpha"), "got: {}", out); + assert!(out.contains("srv"), "got: {}", out); + } + + /// Registers a second MCP server on an existing manager. Mirrors the + /// shape of `setup_client_with_fake` for the second call. + async fn register_fake( + mgr: &Arc, + server_name: &str, + resources: HashMap, + ) { + let fake: Arc = Arc::new(FakeMcp::new(resources)); + mgr.add_client( + server_name.to_string(), + ExtensionConfig::Builtin { + name: server_name.to_string(), + display_name: Some(server_name.to_string()), + description: "fake mcp".to_string(), + timeout: None, + bundled: None, + available_tools: vec![], + }, + fake, + None, + None, + Some("s"), + ) + .await; + } + + #[tokio::test] + async fn test_mcp_vs_mcp_collision_renders_prefixed_names() { + // Two servers publish the same skill name. Dynamic instructions + // must render BOTH with `__` so the model can + // address them unambiguously. + let tmp = TempDir::new().unwrap(); + let mut r1 = HashMap::new(); + r1.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"shared","type":"skill-md","description":"from one","url":"skill://shared/SKILL.md"}"#, + ), + ); + let (client, mgr, _tmp) = + setup_client_with_fake("one", r1, tmp.path().to_path_buf()).await; + + let mut r2 = HashMap::new(); + r2.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"shared","type":"skill-md","description":"from two","url":"skill://shared/SKILL.md"}"#, + ), + ); + register_fake(&mgr, "two", r2).await; + + let out = client + .get_dynamic_instructions("s") + .await + .expect("dynamic output"); + assert!(out.contains("one__shared"), "missing one__shared; got:\n{}", out); + assert!(out.contains("two__shared"), "missing two__shared; got:\n{}", out); + // Bare "shared" alone (followed by a space or paren) must NOT appear + // as a display name — only the prefixed forms. + assert!( + !out.contains("• shared "), + "bare 'shared' should not be rendered when collision exists; got:\n{}", + out + ); + } + + #[tokio::test] + async fn test_fs_vs_mcp_collision_renders_prefixed() { + // A filesystem skill named "shared" coexists with an MCP skill + // of the same name. The MCP entry must be rendered prefixed so + // the model can still reach it. + let tmp = TempDir::new().unwrap(); + let fs_skill_dir = tmp.path().join(".goose/skills/shared"); + fs::create_dir_all(&fs_skill_dir).unwrap(); + fs::write( + fs_skill_dir.join("SKILL.md"), + "---\nname: shared\ndescription: local\n---\nbody", + ) + .unwrap(); + + let mut r = HashMap::new(); + r.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"shared","type":"skill-md","description":"from mcp","url":"skill://shared/SKILL.md"}"#, + ), + ); + let (client, _mgr, _tmp) = + setup_client_with_fake("srv", r, tmp.path().to_path_buf()).await; + + let out = client + .get_dynamic_instructions("s") + .await + .expect("dynamic output"); + assert!( + out.contains("srv__shared"), + "MCP entry should be prefixed against FS collision; got:\n{}", + out + ); + } + + #[tokio::test] + async fn test_load_skill_resolves_server_prefix() { + // With two MCP servers publishing the same skill name, the model + // can disambiguate by using `__` as the load_skill + // argument. + let tmp = TempDir::new().unwrap(); + let mut r1 = HashMap::new(); + r1.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"shared","type":"skill-md","description":"","url":"skill://shared/SKILL.md"}"#, + ), + ); + r1.insert( + "skill://shared/SKILL.md".to_string(), + "body from server one".to_string(), + ); + let (client, mgr, _tmp) = + setup_client_with_fake("one", r1, tmp.path().to_path_buf()).await; + + let mut r2 = HashMap::new(); + r2.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"shared","type":"skill-md","description":"","url":"skill://shared/SKILL.md"}"#, + ), + ); + r2.insert( + "skill://shared/SKILL.md".to_string(), + "body from server two".to_string(), + ); + register_fake(&mgr, "two", r2).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "two__shared"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + let body = text_of(&result); + assert!( + body.contains("body from server two"), + "prefix should route to server two; got:\n{}", + body + ); + assert!(!body.contains("body from server one")); + } + + #[tokio::test] + async fn test_load_skill_literal_name_wins_over_prefix_split() { + // A server publishes a skill whose name contains `__`. A second + // server coincidentally matches the left half of the split, with a + // skill matching the right half. `load_skill` called with the + // literal name must route to the literal entry, not the pair. + let tmp = TempDir::new().unwrap(); + + // Server "srv" hosts a skill literally named "foo__bar". + let mut r1 = HashMap::new(); + r1.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"foo__bar","type":"skill-md","description":"","url":"skill://foo__bar/SKILL.md"}"#, + ), + ); + r1.insert( + "skill://foo__bar/SKILL.md".to_string(), + "literal foo__bar body".to_string(), + ); + let (client, mgr, _tmp) = + setup_client_with_fake("srv", r1, tmp.path().to_path_buf()).await; + + // Server "foo" hosts skill "bar" — creates the ambiguity. + let mut r2 = HashMap::new(); + r2.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"bar","type":"skill-md","description":"","url":"skill://bar/SKILL.md"}"#, + ), + ); + r2.insert( + "skill://bar/SKILL.md".to_string(), + "foo's bar body".to_string(), + ); + register_fake(&mgr, "foo", r2).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "foo__bar"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + let body = text_of(&result); + assert!( + body.contains("literal foo__bar body"), + "literal skill name must win over server/skill prefix split; got:\n{}", + body + ); + assert!(!body.contains("foo's bar body")); + } + + #[tokio::test] + async fn test_load_mcp_skill_lists_supporting_files_like_fs() { + // MCP `load_skill` must surface a "Supporting Files" hint section + // that mirrors the FS behavior: relative paths under the skill's + // base_uri, rendered as `load_skill(name: "/")` + // pointers. Content of the supporting resources must NOT be + // included — only their names/paths. + let tmp = TempDir::new().unwrap(); + let mut resources = HashMap::new(); + resources.insert( + "skill://index.json".to_string(), + index_json( + r#"{"name":"docs","type":"skill-md","description":"D","url":"skill://docs/SKILL.md"}"#, + ), + ); + resources.insert( + "skill://docs/SKILL.md".to_string(), + "main skill body".to_string(), + ); + resources.insert( + "skill://docs/references/GUIDE.md".to_string(), + "SHOULD NOT APPEAR IN OUTPUT".to_string(), + ); + resources.insert( + "skill://docs/templates/EXAMPLE.txt".to_string(), + "ALSO SHOULD NOT APPEAR".to_string(), + ); + // A sibling resource outside this skill's base_uri — must be + // filtered out of the Supporting Files section. + resources.insert( + "skill://other/SKILL.md".to_string(), + "unrelated".to_string(), + ); + + let (client, _mgr, _tmp_guard) = + setup_client_with_fake("srv", resources, tmp.path().to_path_buf()).await; + + let ctx = ToolCallContext::new("s".to_string(), None, None); + let args: JsonObject = + serde_json::from_value(serde_json::json!({"name": "docs"})).unwrap(); + let result = client + .call_tool(&ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .unwrap(); + + assert!(!result.is_error.unwrap_or(false)); + let body = text_of(&result); + + assert!(body.contains("main skill body"), "missing SKILL.md body; got:\n{}", body); + assert!(body.contains("Supporting Files"), "missing section header; got:\n{}", body); + assert!( + body.contains("references/GUIDE.md → load_skill(name: \"docs/references/GUIDE.md\")"), + "missing GUIDE.md pointer; got:\n{}", + body + ); + assert!( + body.contains("templates/EXAMPLE.txt → load_skill(name: \"docs/templates/EXAMPLE.txt\")"), + "missing EXAMPLE.txt pointer; got:\n{}", + body + ); + + // Critical: supporting-file *content* must not leak into context + // alongside SKILL.md. + assert!( + !body.contains("SHOULD NOT APPEAR IN OUTPUT"), + "GUIDE.md content must not be auto-loaded; got:\n{}", + body + ); + assert!( + !body.contains("ALSO SHOULD NOT APPEAR"), + "EXAMPLE.txt content must not be auto-loaded; got:\n{}", + body + ); + // Unrelated sibling must not be listed. + assert!( + !body.contains("skill://other/SKILL.md"), + "cross-skill resource must not appear; got:\n{}", + body + ); + } +} diff --git a/crates/goose/src/skills/mcp_client.rs b/crates/goose/src/skills/mcp_client.rs new file mode 100644 index 000000000000..4b1df99eaac0 --- /dev/null +++ b/crates/goose/src/skills/mcp_client.rs @@ -0,0 +1,436 @@ +//! MCP-served Agent Skills discovery, per SEP `io.modelcontextprotocol/skills`. +//! +//! Bridges skills served over MCP (via `skill://` or any index-listed URI) +//! into Goose's existing skills pipeline. This module is the discovery layer: +//! it reads a server's `skill://index.json`, parses concrete skill entries, +//! and returns [`McpSkillEntry`] values that the skills platform extension +//! caches and surfaces in the system prompt. +//! +//! Scheme-agnostic: the SEP permits servers to list skills under a +//! domain-native URI scheme (e.g. `github://owner/repo/.../SKILL.md`) so long +//! as the entry appears in `skill://index.json` with `type: "skill-md"`. +//! +//! Security: per the SEP, skill content from MCP servers is UNTRUSTED model +//! input. This module extracts only `name`, `description`, and URI locators +//! from the index — never execution-capable fields. + +use rmcp::model::{InitializeResult, ResourceContents}; +use serde::Deserialize; +use std::time::Duration; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +use crate::agents::mcp_client::McpClientTrait; + +/// Extension identifier per the SEP. +pub(crate) const SKILLS_EXTENSION_ID: &str = "io.modelcontextprotocol/skills"; + +/// Well-known index resource URI. Fixed by the SEP — always read from +/// `skill://index.json` regardless of which scheme the listed skills use. +pub(crate) const INDEX_URI: &str = "skill://index.json"; + +/// How long to wait for a server's index fetch before giving up. +/// Applied at extension-registration time so a misbehaving server cannot +/// stall session startup indefinitely. An empty cache on timeout is +/// acceptable — a future `notifications/resources/list_changed` or +/// explicit UI refresh repopulates. +pub(crate) const INDEX_FETCH_TIMEOUT: Duration = Duration::from_secs(5); + +/// A single indexed skill served over MCP, as surfaced to the skills +/// platform extension. `uri` is stored as-is from the index (any scheme); +/// `base_uri` is `uri` with trailing `SKILL.md` stripped for relative-ref +/// composition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpSkillEntry { + pub server: String, + pub name: String, + pub description: String, + pub base_uri: String, + pub uri: String, +} + +/// Returns true if the server's initialize response declares the skills +/// extension capability. Informational only — per the SEP, hosts MUST +/// attempt `skill://index.json` regardless. +pub fn server_declares_skills_capability(info: &InitializeResult) -> bool { + info.capabilities + .extensions + .as_ref() + .is_some_and(|m| m.contains_key(SKILLS_EXTENSION_ID)) +} + +/// Minimal index shape matching the SEP / agentskills.io discovery schema. +/// Lenient: unknown fields are ignored; unknown `type` values cause the +/// entry to be skipped (handled by the caller). +#[derive(Debug, Deserialize)] +struct IndexDoc { + #[serde(default)] + skills: Vec, +} + +#[derive(Debug, Deserialize)] +struct IndexEntry { + #[serde(default)] + name: Option, + #[serde(default, rename = "type")] + entry_type: Option, + #[serde(default)] + description: Option, + #[serde(default)] + url: Option, +} + +/// Fetches and parses `skill://index.json` from a single MCP server via its +/// client handle. Returns an empty vec (with a log) on any failure — this +/// function MUST NOT propagate errors because it runs during extension +/// registration and must not block the agent from starting. +/// +/// Caller supplies the server name (extension key) because it's stamped +/// into each returned entry's `server` field for later routing. +pub async fn fetch_server_skills( + server: &str, + client: &dyn McpClientTrait, + session_id: &str, + cancel: CancellationToken, +) -> Vec { + let fetch = async { + let read = client + .read_resource(session_id, INDEX_URI, cancel.clone()) + .await?; + + let text = read + .contents + .into_iter() + .find_map(|c| match c { + ResourceContents::TextResourceContents { text, .. } => Some(text), + _ => None, + }) + .ok_or("index resource contained no text contents")?; + + let doc: IndexDoc = serde_json::from_str(&text) + .map_err(|e| format!("failed to parse {}: {}", INDEX_URI, e))?; + + Ok::<_, Box>(doc) + }; + + let doc = match tokio::time::timeout(INDEX_FETCH_TIMEOUT, fetch).await { + Ok(Ok(doc)) => doc, + Ok(Err(e)) => { + debug!(server, error = %e, "skill index fetch: no usable index"); + return Vec::new(); + } + Err(_) => { + warn!( + server, + timeout_secs = INDEX_FETCH_TIMEOUT.as_secs(), + "skill index fetch timed out" + ); + return Vec::new(); + } + }; + + let mut entries = Vec::new(); + for raw in doc.skills { + if let Some(entry) = parse_index_entry(server, raw) { + entries.push(entry); + } + } + entries +} + +fn parse_index_entry(server: &str, raw: IndexEntry) -> Option { + match raw.entry_type.as_deref() { + Some("skill-md") => {} + Some("mcp-resource-template") => { + // Templates are deferred — the SEP wires them to the MCP + // completion API, which this implementation does not yet surface. + return None; + } + Some(other) => { + debug!(server, entry_type = other, "skipping unknown index entry type"); + return None; + } + None => { + debug!(server, "skipping index entry with no type"); + return None; + } + } + + let name = raw.name.filter(|s| !s.is_empty()).or_else(|| { + warn!(server, "skill-md index entry missing required `name`"); + None + })?; + let description = raw.description.unwrap_or_default(); + let url = raw.url.filter(|s| !s.is_empty()).or_else(|| { + warn!(server, name, "skill-md index entry missing required `url`"); + None + })?; + + let Some(base_uri) = url.strip_suffix("SKILL.md") else { + warn!( + server, + name, url, "skill-md index entry `url` does not end in SKILL.md — skipping" + ); + return None; + }; + + Some(McpSkillEntry { + server: server.to_string(), + name, + description, + base_uri: base_uri.to_string(), + uri: url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use rmcp::model::{ + CallToolResult, ExtensionCapabilities, InitializeResult, JsonObject, ListResourcesResult, + ListToolsResult, ReadResourceResult, ServerCapabilities, ServerNotification, + }; + use std::collections::HashMap; + use tokio::sync::mpsc; + + use crate::agents::mcp_client::Error; + use crate::agents::ToolCallContext; + + /// Test double — exposes a fixed set of resources and no tools. + pub struct FakeSkillsServer { + pub info: InitializeResult, + pub resources: HashMap, + pub delay: Option, + } + + impl FakeSkillsServer { + fn with_capability() -> InitializeResult { + let mut caps = ExtensionCapabilities::new(); + caps.insert(SKILLS_EXTENSION_ID.to_string(), JsonObject::new()); + InitializeResult::new( + ServerCapabilities::builder() + .enable_resources() + .enable_extensions_with(caps) + .build(), + ) + } + + fn without_capability() -> InitializeResult { + InitializeResult::new(ServerCapabilities::builder().enable_resources().build()) + } + } + + #[async_trait] + impl McpClientTrait for FakeSkillsServer { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _ctx: &ToolCallContext, + _name: &str, + _arguments: Option, + _cancel_token: CancellationToken, + ) -> Result { + unreachable!("FakeSkillsServer has no tools") + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn list_resources( + &self, + _session_id: &str, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + _session_id: &str, + uri: &str, + _cancel_token: CancellationToken, + ) -> Result { + if let Some(delay) = self.delay { + tokio::time::sleep(delay).await; + } + match self.resources.get(uri) { + Some(text) => Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: uri.to_string(), + mime_type: None, + text: text.clone(), + meta: None, + }, + ])), + None => Err(Error::TransportClosed), + } + } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } + } + + fn index_with(entries: &str) -> String { + format!( + r#"{{"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{}]}}"#, + entries + ) + } + + #[test] + fn test_server_declares_capability() { + let declared = FakeSkillsServer::with_capability(); + assert!(server_declares_skills_capability(&declared)); + + let undeclared = FakeSkillsServer::without_capability(); + assert!(!server_declares_skills_capability(&undeclared)); + } + + #[tokio::test] + async fn test_discover_via_index_json() { + let mut resources = HashMap::new(); + resources.insert( + INDEX_URI.to_string(), + index_with( + r#"{"name":"git-workflow","type":"skill-md","description":"Git conventions","url":"skill://git-workflow/SKILL.md"}, + {"name":"refunds","type":"skill-md","description":"Process refunds","url":"skill://acme/billing/refunds/SKILL.md"}"#, + ), + ); + let server = FakeSkillsServer { + info: FakeSkillsServer::with_capability(), + resources, + delay: None, + }; + + let entries = + fetch_server_skills("gh", &server as &dyn McpClientTrait, "s", CancellationToken::new()) + .await; + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "git-workflow"); + assert_eq!(entries[0].uri, "skill://git-workflow/SKILL.md"); + assert_eq!(entries[0].base_uri, "skill://git-workflow/"); + assert_eq!(entries[0].server, "gh"); + assert_eq!(entries[1].name, "refunds"); + assert_eq!(entries[1].base_uri, "skill://acme/billing/refunds/"); + } + + #[tokio::test] + async fn test_discover_tolerates_missing_index() { + let server = FakeSkillsServer { + info: FakeSkillsServer::with_capability(), + resources: HashMap::new(), + delay: None, + }; + + let entries = + fetch_server_skills("gh", &server as &dyn McpClientTrait, "s", CancellationToken::new()) + .await; + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn test_discover_skips_templates() { + let mut resources = HashMap::new(); + resources.insert( + INDEX_URI.to_string(), + index_with( + r#"{"name":"real","type":"skill-md","description":"","url":"skill://real/SKILL.md"}, + {"type":"mcp-resource-template","description":"Per-product docs","url":"skill://docs/{product}/SKILL.md"}"#, + ), + ); + let server = FakeSkillsServer { + info: FakeSkillsServer::with_capability(), + resources, + delay: None, + }; + + let entries = + fetch_server_skills("gh", &server as &dyn McpClientTrait, "s", CancellationToken::new()) + .await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "real"); + } + + #[tokio::test] + async fn test_discover_accepts_non_skill_scheme() { + let mut resources = HashMap::new(); + resources.insert( + INDEX_URI.to_string(), + index_with( + r#"{"name":"pull-requests","type":"skill-md","description":"","url":"github://github/repo/skills/pull-requests/SKILL.md"}"#, + ), + ); + let server = FakeSkillsServer { + info: FakeSkillsServer::with_capability(), + resources, + delay: None, + }; + + let entries = fetch_server_skills( + "github", + &server as &dyn McpClientTrait, + "s", + CancellationToken::new(), + ) + .await; + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].uri, + "github://github/repo/skills/pull-requests/SKILL.md" + ); + assert_eq!( + entries[0].base_uri, + "github://github/repo/skills/pull-requests/" + ); + } + + #[tokio::test] + async fn test_discover_timeout_does_not_block() { + // Server that sleeps longer than the fetch timeout. + let server = FakeSkillsServer { + info: FakeSkillsServer::with_capability(), + resources: HashMap::new(), + delay: Some(INDEX_FETCH_TIMEOUT + Duration::from_millis(500)), + }; + + let start = std::time::Instant::now(); + let entries = fetch_server_skills( + "slow", + &server as &dyn McpClientTrait, + "s", + CancellationToken::new(), + ) + .await; + let elapsed = start.elapsed(); + + assert!(entries.is_empty()); + // Should have bailed after the timeout, not waited for the server. + assert!( + elapsed < INDEX_FETCH_TIMEOUT + Duration::from_millis(500), + "fetch took {:?}, should have timed out", + elapsed + ); + } + +} diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs new file mode 100644 index 000000000000..8bee17983752 --- /dev/null +++ b/crates/goose/src/skills/mod.rs @@ -0,0 +1,387 @@ +//! Everything specific to skills: filesystem discovery (`SKILL.md` walking + +//! built-ins) and the runtime MCP client (`client` submodule). User-facing +//! CRUD lives in `crate::sources`, which generalizes across source types. + +mod builtin; +pub mod client; +pub mod mcp_client; + +pub use client::{SkillsClient, EXTENSION_NAME}; + +use crate::config::paths::Paths; +use crate::sources::parse_frontmatter; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use sacp::Error; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::warn; + +#[derive(Debug, Deserialize)] +pub struct SkillFrontmatter { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: String, +} + +/// Canonical writable location for global user skills: `~/.agents/skills`. +pub fn global_skills_dir() -> Option { + dirs::home_dir().map(|h| h.join(".agents").join("skills")) +} + +/// Canonical writable location for project-scoped skills: +/// `/.goose/skills`. +pub fn project_skills_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".goose").join("skills") +} + +pub(crate) fn skills_dir_global_or_err() -> Result { + global_skills_dir() + .ok_or_else(|| Error::internal_error().data("Could not determine home directory")) +} + +pub(crate) fn skills_dir_project_or_err(project_dir: &str) -> Result { + if project_dir.trim().is_empty() { + return Err( + Error::invalid_params().data("projectDir must not be empty when global is false") + ); + } + Ok(project_skills_dir(Path::new(project_dir))) +} + +pub(crate) fn skill_base_dir(global: bool, project_dir: Option<&str>) -> Result { + if global { + skills_dir_global_or_err() + } else { + let pd = project_dir.ok_or_else(|| { + Error::invalid_params().data("projectDir is required when global is false") + })?; + skills_dir_project_or_err(pd) + } +} + +pub(crate) fn validate_skill_name(name: &str) -> Result<(), Error> { + if name.is_empty() { + return Err(Error::invalid_params().data("Skill name must not be empty")); + } + if name.len() > 64 { + return Err(Error::invalid_params().data(format!( + "Invalid skill name \"{}\". Names must be at most 64 characters.", + name + ))); + } + if !name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(Error::invalid_params().data(format!( + "Invalid skill name \"{}\". Names may only contain lowercase letters, digits, and hyphens.", + name + ))); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(Error::invalid_params().data(format!( + "Invalid skill name \"{}\". Names must not start or end with a hyphen.", + name + ))); + } + Ok(()) +} + +fn canonicalize_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn inferred_discoverable_skill_root(path: &Path) -> Option { + let canonical_path = canonicalize_or_original(path); + + let mut global_roots = Vec::new(); + if let Some(global_root) = global_skills_dir() { + global_roots.push(global_root); + } + global_roots.push(Paths::config_dir().join("skills")); + if let Some(home) = dirs::home_dir() { + global_roots.push(home.join(".claude").join("skills")); + global_roots.push(home.join(".config").join("agents").join("skills")); + } + + for root in global_roots { + let canonical_root = canonicalize_or_original(&root); + if canonical_path.starts_with(&canonical_root) { + return Some(canonical_root); + } + } + + canonical_path.ancestors().find_map(|ancestor| { + let parent = ancestor.parent()?; + let is_project_skills_root = ancestor.file_name().and_then(|name| name.to_str()) + == Some("skills") + && matches!( + parent.file_name().and_then(|name| name.to_str()), + Some(".goose") | Some(".claude") | Some(".agents") + ); + is_project_skills_root.then(|| ancestor.to_path_buf()) + }) +} + +pub(crate) fn resolve_discoverable_skill_dir(path: &str) -> Result { + if path.is_empty() { + return Err(Error::invalid_params().data("Source path must not be empty")); + } + + let canonical_dir = Path::new(path) + .canonicalize() + .map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?; + + if inferred_discoverable_skill_root(&canonical_dir).is_none() + || !canonical_dir.is_dir() + || !canonical_dir.join("SKILL.md").is_file() + { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path))); + } + + Ok(canonical_dir) +} + +pub(crate) fn resolve_skill_dir(path: &str) -> Result { + resolve_discoverable_skill_dir(path) +} + +pub(crate) fn is_global_skill_dir(path: &Path) -> bool { + global_skills_dir().as_deref().is_some_and(|root| { + canonicalize_or_original(path).starts_with(canonicalize_or_original(root)) + }) +} + +pub(crate) fn infer_skill_name(dir: &Path) -> String { + let md = dir.join("SKILL.md"); + if let Ok(raw) = std::fs::read_to_string(&md) { + if let Ok(Some((meta, _))) = parse_frontmatter::(&raw) { + if let Some(n) = meta.name.filter(|n| !n.is_empty()) { + return n; + } + } + } + dir.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed") + .to_string() +} + +pub(crate) fn build_skill_md(name: &str, description: &str, content: &str) -> String { + let safe_desc = description.replace('\'', "''"); + let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); + if !content.is_empty() { + md.push('\n'); + md.push_str(content); + md.push('\n'); + } + md +} + +pub(crate) fn parse_skill_frontmatter(raw: &str) -> (String, String) { + if !raw.trim_start().starts_with("---") { + return (String::new(), raw.to_string()); + } + match parse_frontmatter::(raw) { + Ok(Some((meta, body))) => (meta.description, body), + _ => (String::new(), raw.to_string()), + } +} + +/// Every directory the agent reads skills from, paired with whether each is a +/// global (home-rooted) location. Order matches discovery precedence: project +/// dirs first, then global dirs. +pub fn all_skill_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> { + let mut dirs: Vec<(PathBuf, bool)> = Vec::new(); + + if let Some(wd) = working_dir { + dirs.push((wd.join(".goose").join("skills"), false)); + dirs.push((wd.join(".claude").join("skills"), false)); + dirs.push((wd.join(".agents").join("skills"), false)); + } + + let home = dirs::home_dir(); + if let Some(h) = home.as_ref() { + dirs.push((h.join(".agents").join("skills"), true)); + } + dirs.push((Paths::config_dir().join("skills"), true)); + if let Some(h) = home.as_ref() { + dirs.push((h.join(".claude").join("skills"), true)); + dirs.push((h.join(".config").join("agents").join("skills"), true)); + } + + dirs +} + +fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option { + let (metadata, body): (SkillFrontmatter, String) = match parse_frontmatter(content) { + Ok(Some(parsed)) => parsed, + Ok(None) => return None, + Err(e) => { + warn!("Failed to parse skill frontmatter: {}", e); + return None; + } + }; + + let name = match metadata.name.filter(|n| !n.is_empty()) { + Some(n) => n, + None => { + warn!( + "Skill at '{}' is missing a required 'name' in frontmatter, skipping", + path.display() + ); + return None; + } + }; + + if name.contains('/') { + warn!("Skill name '{}' contains '/', skipping", name); + return None; + } + + Some(SourceEntry { + source_type: SourceType::Skill, + name, + description: metadata.description, + content: body, + directory: path.to_string_lossy().into_owned(), + global, + supporting_files: Vec::new(), + }) +} + +fn should_skip_dir(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git") | Some(".hg") | Some(".svn") + ) +} + +fn walk_files_recursively( + dir: &Path, + visited_dirs: &mut HashSet, + should_descend: &mut G, + visit_file: &mut F, +) where + F: FnMut(&Path), + G: FnMut(&Path) -> bool, +{ + let canonical_dir = match std::fs::canonicalize(dir) { + Ok(path) => path, + Err(_) => return, + }; + + if !visited_dirs.insert(canonical_dir) { + return; + } + + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if should_descend(&path) { + walk_files_recursively(&path, visited_dirs, should_descend, visit_file); + } + } else if path.is_file() { + visit_file(&path); + } + } +} + +fn scan_skills_from_dir(dir: &Path, global: bool, seen: &mut HashSet) -> Vec { + let mut skill_files = Vec::new(); + let mut visited_dirs = HashSet::new(); + + walk_files_recursively( + dir, + &mut visited_dirs, + &mut |path| !should_skip_dir(path), + &mut |path| { + if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") { + skill_files.push(path.to_path_buf()); + } + }, + ); + + let mut sources = Vec::new(); + for skill_file in skill_files { + let Some(skill_dir) = skill_file.parent() else { + continue; + }; + let content = match std::fs::read_to_string(&skill_file) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read skill file {}: {}", skill_file.display(), e); + continue; + } + }; + + if let Some(mut source) = parse_skill_content(&content, skill_dir, global) { + if !seen.contains(&source.name) { + let mut files = Vec::new(); + let mut visited_support_dirs = HashSet::new(); + walk_files_recursively( + skill_dir, + &mut visited_support_dirs, + &mut |path| !should_skip_dir(path) && !path.join("SKILL.md").is_file(), + &mut |path| { + if path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") { + files.push(path.to_string_lossy().into_owned()); + } + }, + ); + source.supporting_files = files; + + seen.insert(source.name.clone()); + sources.push(source); + } + } + } + sources +} + +/// Discover skills from all configured filesystem locations and built-ins. +/// Each returned entry has `global` set according to the directory it was +/// found in (or `true` for built-ins). +pub fn discover_skills(working_dir: Option<&Path>) -> Vec { + let mut sources: Vec = Vec::new(); + let mut seen = HashSet::new(); + + for (dir, is_global) in all_skill_dirs(working_dir) { + for source in scan_skills_from_dir(&dir, is_global, &mut seen) { + sources.push(source); + } + } + + for content in builtin::get_all() { + if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) { + if !seen.contains(&source.name) { + seen.insert(source.name.clone()); + sources.push(SourceEntry { + source_type: SourceType::BuiltinSkill, + ..source + }); + } + } + } + + sources +} + +pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec { + let fallback; + let wd = match working_dir { + Some(p) => Some(p), + None => { + fallback = std::env::current_dir().ok(); + fallback.as_deref() + } + }; + discover_skills(wd) +} diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs new file mode 100644 index 000000000000..f6d9c27eeb27 --- /dev/null +++ b/crates/goose/src/sources.rs @@ -0,0 +1,577 @@ +//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom + +use crate::skills::{ + build_skill_md, discover_skills, infer_skill_name, is_global_skill_dir, + parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir, + validate_skill_name, +}; +use fs_err as fs; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use sacp::Error; +use serde::Deserialize; +use std::path::PathBuf; + +pub fn parse_frontmatter Deserialize<'de>>( + content: &str, +) -> Result, serde_yaml::Error> { + let parts: Vec<&str> = content.split("---").collect(); + if parts.len() < 3 { + return Ok(None); + } + + let yaml_content = parts[1].trim(); + let metadata: T = serde_yaml::from_str(yaml_content)?; + + let body = parts[2..].join("---").trim().to_string(); + Ok(Some((metadata, body))) +} + +fn require_skill_type(source_type: SourceType) -> Result<(), Error> { + if source_type != SourceType::Skill { + return Err(Error::invalid_params().data(format!( + "Source type '{}' is not supported. Only 'skill' is currently supported.", + source_type + ))); + } + Ok(()) +} + +fn source_entry( + source_type: SourceType, + name: &str, + description: &str, + content: &str, + dir: &std::path::Path, + global: bool, +) -> SourceEntry { + SourceEntry { + source_type, + name: name.to_string(), + description: description.to_string(), + content: content.to_string(), + directory: dir.to_string_lossy().to_string(), + global, + supporting_files: Vec::new(), + } +} + +pub fn create_source( + source_type: SourceType, + name: &str, + description: &str, + content: &str, + global: bool, + project_dir: Option<&str>, +) -> Result { + require_skill_type(source_type)?; + validate_skill_name(name)?; + let dir = skill_base_dir(global, project_dir)?.join(name); + + if dir.exists() { + return Err( + Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) + ); + } + + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &dir, + global, + )) +} + +pub fn update_source( + source_type: SourceType, + path: &str, + name: &str, + description: &str, + content: &str, +) -> Result { + require_skill_type(source_type)?; + validate_skill_name(name)?; + + let dir = resolve_discoverable_skill_dir(path)?; + let current_dir_name = dir + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| Error::internal_error().data("Failed to resolve source directory name"))?; + + let target_dir = if name == current_dir_name { + dir.clone() + } else { + let base_dir = dir.parent().ok_or_else(|| { + Error::internal_error().data("Failed to resolve source base directory") + })?; + let target_dir = base_dir.join(name); + + if target_dir.exists() { + return Err( + Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) + ); + } + + fs::rename(&dir, &target_dir).map_err(|e| { + Error::internal_error().data(format!("Failed to rename source directory: {e}")) + })?; + + target_dir + }; + + let file_path = target_dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &target_dir, + is_global_skill_dir(&target_dir), + )) +} + +pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> { + require_skill_type(source_type)?; + let dir = resolve_skill_dir(path)?; + fs::remove_dir_all(&dir) + .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; + Ok(()) +} + +pub fn list_sources( + source_type: Option, + project_dir: Option<&str>, +) -> Result, Error> { + if let Some(t) = source_type { + require_skill_type(t)?; + } + + let working_dir = project_dir + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + + let mut sources: Vec = discover_skills(working_dir.as_deref()) + .into_iter() + .filter(|s| s.source_type == SourceType::Skill) + .collect(); + + sources.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(sources) +} + +pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> { + require_skill_type(source_type)?; + let dir = resolve_discoverable_skill_dir(path)?; + + let md = dir.join("SKILL.md"); + let raw = fs::read_to_string(&md) + .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; + let (description, content) = parse_skill_frontmatter(&raw); + + let name = infer_skill_name(&dir); + + let export = serde_json::json!({ + "version": 1, + "type": "skill", + "name": name, + "description": description, + "content": content, + }); + let json = serde_json::to_string_pretty(&export) + .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; + let filename = format!("{}.skill.json", name); + Ok((json, filename)) +} + +pub fn import_sources( + data: &str, + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let value: serde_json::Value = serde_json::from_str(data) + .map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?; + + let version = value + .get("version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?; + if version != 1 { + return Err( + Error::invalid_params().data(format!("Unsupported source export version: {}", version)) + ); + } + + match value + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("skill") + { + "skill" => {} + other => { + return Err(Error::invalid_params().data(format!( + "Source type '{}' is not supported. Only 'skill' is currently supported.", + other + ))); + } + }; + + let name = value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))? + .to_string(); + if name.is_empty() { + return Err(Error::invalid_params().data("Source name must not be empty")); + } + + let description = value + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? + .to_string(); + if description.is_empty() { + return Err(Error::invalid_params().data("Source description must not be empty")); + } + + let content = value + .get("content") + .or_else(|| value.get("instructions")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + validate_skill_name(&name)?; + + let base = skill_base_dir(global, project_dir)?; + let mut final_name = name.clone(); + if base.join(&final_name).exists() { + final_name = format!("{}-imported", name); + let mut counter = 2u32; + while base.join(&final_name).exists() { + final_name = format!("{}-imported-{}", name, counter); + counter += 1; + } + } + + let dir = base.join(&final_name); + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(&final_name, &description, &content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(vec![source_entry( + SourceType::Skill, + &final_name, + &description, + &content, + &dir, + global, + )]) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn skill_name_validation() { + assert!(validate_skill_name("my-skill").is_ok()); + assert!(validate_skill_name("abc123").is_ok()); + assert!(validate_skill_name("double--hyphen").is_ok()); + assert!(validate_skill_name("").is_err()); + assert!(validate_skill_name("-leading").is_err()); + assert!(validate_skill_name("trailing-").is_err()); + assert!(validate_skill_name("CAPS").is_err()); + assert!(validate_skill_name("../escape").is_err()); + assert!(validate_skill_name(&"a".repeat(64)).is_ok()); + assert!(validate_skill_name(&"a".repeat(65)).is_err()); + } + + #[test] + fn create_list_update_delete_project_skill() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + let created = create_source( + SourceType::Skill, + "my-skill", + "does the thing", + "step one\nstep two", + false, + Some(project), + ) + .unwrap(); + assert_eq!(created.name, "my-skill"); + assert!(!created.global); + let dir = PathBuf::from(&created.directory); + assert!(dir.join("SKILL.md").exists()); + + let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); + assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); + + let updated = update_source( + SourceType::Skill, + created.directory.as_str(), + "my-skill", + "now does a different thing", + "step three", + ) + .unwrap(); + assert_eq!(updated.description, "now does a different thing"); + assert_eq!(updated.name, "my-skill"); + + delete_source(SourceType::Skill, created.directory.as_str()).unwrap(); + assert!(!dir.exists()); + } + + #[test] + fn create_rejects_duplicate_name() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap(); + let err = + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err(); + assert!(format!("{:?}", err).contains("already exists")); + } + + #[test] + fn project_scope_requires_project_dir() { + let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err(); + assert!(format!("{:?}", err).contains("projectDir")); + } + + #[test] + fn export_then_import_roundtrip() { + let tmp = TempDir::new().unwrap(); + let project_a = tmp.path().join("a"); + let project_b = tmp.path().join("b"); + std::fs::create_dir_all(&project_a).unwrap(); + std::fs::create_dir_all(&project_b).unwrap(); + + create_source( + SourceType::Skill, + "portable", + "describes itself", + "body goes here", + false, + Some(project_a.to_str().unwrap()), + ) + .unwrap(); + + let portable_dir = project_a.join(".goose").join("skills").join("portable"); + let (json, filename) = + export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); + assert_eq!(filename, "portable.skill.json"); + + let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].name, "portable"); + assert_eq!(imported[0].description, "describes itself"); + assert_eq!(imported[0].content, "body goes here"); + } + + #[test] + fn export_allows_discovered_read_only_skill() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path(); + let claude_skill_dir = project.join(".claude").join("skills").join("portable"); + std::fs::create_dir_all(&claude_skill_dir).unwrap(); + std::fs::write( + claude_skill_dir.join("SKILL.md"), + build_skill_md("portable", "describes itself", "body goes here"), + ) + .unwrap(); + + let listed = + list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); + let exported_skill = listed + .iter() + .find(|skill| skill.name == "portable") + .expect("expected listed skill"); + + let (json, filename) = + export_source(SourceType::Skill, exported_skill.directory.as_str()).unwrap(); + assert_eq!(filename, "portable.skill.json"); + assert!(json.contains("\"name\": \"portable\"")); + } + + #[test] + fn update_allows_discovered_read_only_skill() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path(); + let claude_skill_dir = project.join(".claude").join("skills").join("portable"); + std::fs::create_dir_all(&claude_skill_dir).unwrap(); + std::fs::write( + claude_skill_dir.join("SKILL.md"), + build_skill_md("portable", "describes itself", "body goes here"), + ) + .unwrap(); + + let updated = update_source( + SourceType::Skill, + claude_skill_dir.to_str().unwrap(), + "portable", + "updated description", + "updated body", + ) + .unwrap(); + + assert_eq!(updated.name, "portable"); + assert_eq!(updated.description, "updated description"); + assert_eq!(updated.content, "updated body"); + + let raw = std::fs::read_to_string(claude_skill_dir.join("SKILL.md")).unwrap(); + assert!(raw.contains("description: 'updated description'")); + assert!(raw.contains("updated body")); + } + + #[test] + fn import_collision_appends_suffix() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); + + let payload = serde_json::json!({ + "version": 1, + "type": "skill", + "name": "busy", + "description": "d", + "content": "c", + }) + .to_string(); + let imported = import_sources(&payload, false, Some(project)).unwrap(); + assert_eq!(imported[0].name, "busy-imported"); + } + + #[test] + fn update_rejects_nonexistent_source() { + let tmp = TempDir::new().unwrap(); + let missing_dir = tmp + .path() + .join(".goose") + .join("skills") + .join("no-such-skill"); + let err = update_source( + SourceType::Skill, + missing_dir.to_str().unwrap(), + "no-such-skill", + "d", + "c", + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("not found")); + } + + #[test] + fn delete_rejects_nonexistent_source() { + let tmp = TempDir::new().unwrap(); + let missing_dir = tmp + .path() + .join(".goose") + .join("skills") + .join("no-such-skill"); + let err = delete_source(SourceType::Skill, missing_dir.to_str().unwrap()).unwrap_err(); + assert!(format!("{:?}", err).contains("not found")); + } + + #[test] + fn rejects_non_skill_source_type() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + let err = create_source( + SourceType::BuiltinSkill, + "x", + "d", + "c", + false, + Some(project), + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + + let err = update_source(SourceType::Recipe, "x", "x", "d", "c").unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + + let err = delete_source(SourceType::Subrecipe, "x").unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + + let err = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + + let err = export_source(SourceType::Recipe, "x").unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + } + + #[test] + fn update_derives_name_from_frontmatter() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source( + SourceType::Skill, + "my-dir", + "orig", + "body", + false, + Some(project), + ) + .unwrap(); + + let skill_dir = tmp.path().join(".goose").join("skills").join("my-dir"); + let updated = update_source( + SourceType::Skill, + skill_dir.to_str().unwrap(), + "my-dir", + "new description", + "new body", + ) + .unwrap(); + // Name is derived from the frontmatter written by create_source + assert_eq!(updated.name, "my-dir"); + } + + #[test] + fn update_rejects_path_traversal() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path(); + let escaped_dir = project.join(".goose").join("escaped"); + std::fs::create_dir_all(&escaped_dir).unwrap(); + std::fs::write( + escaped_dir.join("SKILL.md"), + "---\nname: escaped\ndescription: escaped\n---\ncontent", + ) + .unwrap(); + + let attempted_escape = project.join(".goose").join("escaped"); + let err = update_source( + SourceType::Skill, + attempted_escape.to_str().unwrap(), + "escaped", + "new description", + "new content", + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("not found")); + } +} diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs similarity index 90% rename from crates/goose-acp/tests/common_tests/mod.rs rename to crates/goose/tests/acp_common_tests/mod.rs index 1edb3f4678ce..34272c69f6ab 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -2,16 +2,16 @@ #![recursion_limit = "256"] #![allow(unused_attributes)] -#[path = "../fixtures/mod.rs"] +#[path = "../acp_fixtures/mod.rs"] pub mod fixtures; use fixtures::{ assert_notifications, Connection, FsFixture, Notification, OpenAiFixture, PermissionDecision, Session, SessionData, TerminalCall, TerminalFixture, TestConnectionConfig, }; use fs_err as fs; +use goose::acp::server::AcpProviderFactory; use goose::config::base::CONFIG_YAML_NAME; use goose::config::GooseMode; -use goose_acp::server::AcpProviderFactory; use goose_test_support::{McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL}; use sacp::schema::{ ListSessionsResponse, McpServer, McpServerHttp, ModelId, SessionInfo, SessionModeId, @@ -32,7 +32,7 @@ async fn new_basic_session(config: TestConnectionConfig) -> Basic let openai = OpenAiFixture::new( vec![( r#"\nwhat is 1+1""#.into(), - include_str!("../test_data/openai_basic.txt"), + include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), ) @@ -57,12 +57,18 @@ pub async fn run_list_sessions() { let mut response = conn.list_sessions().await.unwrap(); for s in &mut response.sessions { s.updated_at = None; + // createdAt is a dynamic timestamp — verify it exists then remove for comparison. + if let Some(ref mut meta) = s.meta { + assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); + meta.remove("createdAt"); + } } let mut expected_meta = serde_json::Map::new(); expected_meta.insert( "messageCount".to_string(), serde_json::Value::Number(2.into()), ); + expected_meta.insert("userSetName".to_string(), serde_json::Value::Bool(false)); assert_eq!( response, ListSessionsResponse::new(vec![SessionInfo::new( @@ -147,11 +153,11 @@ pub async fn run_config_mcp() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -198,11 +204,11 @@ pub async fn run_fs_read_text_file_true() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_fs_read_tool_call.txt"), + include_str!("../acp_test_data/openai_fs_read_tool_call.txt"), ), ( r#""content":"test-read-content-12345""#.into(), - include_str!("../test_data/openai_fs_read_tool_result.txt"), + include_str!("../acp_test_data/openai_fs_read_tool_result.txt"), ), ], expected_session_id.clone(), @@ -247,11 +253,11 @@ pub async fn run_fs_write_text_file_false() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_fs_write_tool_call.txt"), + include_str!("../acp_test_data/openai_fs_write_tool_call.txt"), ), ( r#"Created /tmp/test_acp_write.txt"#.into(), - include_str!("../test_data/openai_fs_write_tool_result.txt"), + include_str!("../acp_test_data/openai_fs_write_tool_result.txt"), ), ], expected_session_id.clone(), @@ -295,11 +301,11 @@ pub async fn run_fs_write_text_file_true() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_fs_write_tool_call.txt"), + include_str!("../acp_test_data/openai_fs_write_tool_call.txt"), ), ( r#"Created /tmp/test_acp_write.txt"#.into(), - include_str!("../test_data/openai_fs_write_tool_result.txt"), + include_str!("../acp_test_data/openai_fs_write_tool_result.txt"), ), ], expected_session_id.clone(), @@ -366,11 +372,11 @@ pub async fn run_load_mode() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -422,11 +428,13 @@ pub async fn run_load_mode() { } pub async fn run_load_model() { + // Use a Chat Completions model so the canned SSE fixtures parse correctly. + // TODO: add a Responses API mock to OpenAiFixture for responses-routed models. let expected_session_id = C::expected_session_id(); let openai = OpenAiFixture::new( vec![( - r#""model":"o4-mini""#.into(), - include_str!("../test_data/openai_basic.txt"), + r#""model":"gpt-4.1""#.into(), + include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), ) @@ -437,7 +445,7 @@ pub async fn run_load_model() { expected_session_id.set(&session.session_id().0); let session_id = session.session_id().0.to_string(); - conn.set_model(&session_id, "o4-mini").await.unwrap(); + conn.set_model(&session_id, "gpt-4.1").await.unwrap(); let output = session .prompt("what is 1+1", PermissionDecision::Cancel) @@ -446,7 +454,7 @@ pub async fn run_load_model() { assert_eq!(output.text, "2"); let SessionData { models, .. } = conn.load_session(&session_id, vec![]).await.unwrap(); - assert_eq!(&*models.unwrap().current_model_id.0, "o4-mini"); + assert_eq!(&*models.unwrap().current_model_id.0, "gpt-4.1"); } pub async fn run_load_session_mcp() { @@ -460,19 +468,19 @@ pub async fn run_load_session_mcp() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -602,11 +610,11 @@ async fn run_mode_set_impl(via: SetModeVia) { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -773,18 +781,20 @@ enum SetModelVia { } async fn run_model_set_impl(via: SetModelVia) { + // Use a Chat Completions model so the canned SSE fixtures parse correctly. + // TODO: add a Responses API mock to OpenAiFixture for responses-routed models. let expected_session_id = C::expected_session_id(); let openai = OpenAiFixture::new( vec![ // Session B prompt with switched model ( - r#""model":"o4-mini""#.into(), - include_str!("../test_data/openai_basic.txt"), + r#""model":"gpt-4.1""#.into(), + include_str!("../acp_test_data/openai_basic.txt"), ), // Session A prompt with default model ( format!(r#""model":"{TEST_MODEL}""#), - include_str!("../test_data/openai_basic.txt"), + include_str!("../acp_test_data/openai_basic.txt"), ), ], expected_session_id.clone(), @@ -803,23 +813,23 @@ async fn run_model_set_impl(via: SetModelVia) { .. } = conn.new_session().await.unwrap(); - // Session B: switch to o4-mini + // Session B: switch to gpt-4.1 let SessionData { session: mut session_b, .. } = conn.new_session().await.unwrap(); let session_id = &session_b.session_id().0; match via { - SetModelVia::Dedicated => conn.set_model(session_id, "o4-mini").await.unwrap(), + SetModelVia::Dedicated => conn.set_model(session_id, "gpt-4.1").await.unwrap(), SetModelVia::ConfigOption => conn - .set_config_option(session_id, "model", "o4-mini") + .set_config_option(session_id, "model", "gpt-4.1") .await .unwrap(), } let set_model_notifs = session_b.notifications(); - // Prompt B — expects o4-mini + // Prompt B — expects gpt-4.1 expected_session_id.set(&session_b.session_id().0); let output = session_b .prompt("what is 1+1", PermissionDecision::Cancel) @@ -919,11 +929,11 @@ pub async fn run_permission_persistence() { vec![ ( prompt.to_string(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -961,7 +971,7 @@ pub async fn run_prompt_basic() { let openai = OpenAiFixture::new( vec![( r#"\nwhat is 1+1""#.into(), - include_str!("../test_data/openai_basic.txt"), + include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), ) @@ -989,15 +999,15 @@ pub async fn run_prompt_codemode() { vec![ ( format!(r#"\n{prompt}""#), - include_str!("../test_data/openai_builtin_search.txt"), + include_str!("../acp_test_data/openai_builtin_search.txt"), ), ( r#"export async function getCode"#.into(), - include_str!("../test_data/openai_builtin_execute.txt"), + include_str!("../acp_test_data/openai_builtin_execute.txt"), ), ( r#"Created /tmp/result.txt"#.into(), - include_str!("../test_data/openai_builtin_final.txt"), + include_str!("../acp_test_data/openai_builtin_final.txt"), ), ], expected_session_id.clone(), @@ -1037,11 +1047,11 @@ pub async fn run_prompt_image() { ( r#"\nUse the get_image tool and describe what you see in its result.""# .into(), - include_str!("../test_data/openai_image_tool_call.txt"), + include_str!("../acp_test_data/openai_image_tool_call.txt"), ), ( r#""type":"image_url""#.into(), - include_str!("../test_data/openai_image_tool_result.txt"), + include_str!("../acp_test_data/openai_image_tool_result.txt"), ), ], expected_session_id.clone(), @@ -1081,7 +1091,7 @@ pub async fn run_prompt_image_attachment() { let openai = OpenAiFixture::new( vec![( r#""type":"image_url""#.into(), - include_str!("../test_data/openai_image_attachment.txt"), + include_str!("../acp_test_data/openai_image_attachment.txt"), )], expected_session_id.clone(), ) @@ -1112,11 +1122,11 @@ pub async fn run_prompt_mcp() { vec![ ( r#"\nUse the get_code tool and output only its result.""#.into(), - include_str!("../test_data/openai_tool_call.txt"), + include_str!("../acp_test_data/openai_tool_call.txt"), ), ( format!(r#""content":"{FAKE_CODE}""#), - include_str!("../test_data/openai_tool_result.txt"), + include_str!("../acp_test_data/openai_tool_result.txt"), ), ], expected_session_id.clone(), @@ -1152,13 +1162,16 @@ pub async fn run_prompt_mcp() { } pub async fn run_prompt_model_mismatch() { - // Start the connection where the current model is not desired. + // Start the connection where the current model differs from TEST_MODEL. + // Use a Chat Completions model so the canned SSE fixtures parse correctly. + // TODO: add a Responses API mock to OpenAiFixture so we can test with + // responses-routed models like o4-mini here. let config = TestConnectionConfig { - current_model: "o4-mini".to_string(), + current_model: "gpt-4.1".to_string(), ..Default::default() }; - // Server starts on o4-mini; client is configured with TEST_MODEL. + // Server starts on gpt-4.1; client is configured with TEST_MODEL. // If session_model is seeded from the response, stream() detects the // mismatch and sends set_model(TEST_MODEL) before prompting. let BasicSession { conn: _, .. } = new_basic_session::(config).await; @@ -1178,7 +1191,7 @@ pub async fn run_prompt_skill() { let openai = OpenAiFixture::new( vec![( "skill-loaded-in-acp-session".to_string(), - include_str!("../test_data/openai_basic.txt"), + include_str!("../acp_test_data/openai_basic.txt"), )], expected_session_id.clone(), ) @@ -1209,11 +1222,11 @@ pub async fn run_shell_terminal_false() { vec![ ( prompt.clone(), - include_str!("../test_data/openai_shell_tool_call.txt"), + include_str!("../acp_test_data/openai_shell_tool_call.txt"), ), ( SHELL_TEST_CONTENT.into(), - include_str!("../test_data/openai_shell_tool_result.txt"), + include_str!("../acp_test_data/openai_shell_tool_result.txt"), ), ], expected_session_id.clone(), @@ -1252,11 +1265,11 @@ pub async fn run_shell_terminal_true() { vec![ ( prompt.clone(), - include_str!("../test_data/openai_shell_tool_call.txt"), + include_str!("../acp_test_data/openai_shell_tool_call.txt"), ), ( SHELL_TEST_CONTENT.into(), - include_str!("../test_data/openai_shell_tool_result.txt"), + include_str!("../acp_test_data/openai_shell_tool_result.txt"), ), ], expected_session_id.clone(), diff --git a/crates/goose-acp/tests/custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs similarity index 86% rename from crates/goose-acp/tests/custom_requests_test.rs rename to crates/goose/tests/acp_custom_requests_test.rs index 06c970f6e536..a2a5951647ff 100644 --- a/crates/goose-acp/tests/custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -1,4 +1,5 @@ #[allow(dead_code)] +#[path = "acp_common_tests/mod.rs"] mod common_tests; use common_tests::fixtures::server::AcpServerConnection; @@ -6,10 +7,10 @@ use common_tests::fixtures::{ run_test, send_custom, Connection, PermissionDecision, Session, SessionData, TestConnectionConfig, }; +use goose::acp::server::AcpProviderFactory; use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider}; use goose::providers::errors::ProviderError; -use goose_acp::server::AcpProviderFactory; use goose_test_support::{EnforceSessionId, IgnoreSessionId}; use std::sync::{Arc, Mutex}; @@ -112,32 +113,29 @@ fn test_custom_get_extensions() { } #[test] -fn test_custom_list_providers() { +fn test_custom_provider_inventory_includes_metadata() { run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; let response = send_custom(conn.cx(), "_goose/providers/list", serde_json::json!({})) .await - .expect("provider list should succeed"); + .expect("provider inventory should succeed"); let providers = response - .get("providers") + .get("entries") .and_then(|value| value.as_array()) - .expect("missing providers array"); - - assert!( - providers.iter().any(|provider| { - provider.get("id") == Some(&serde_json::json!("goose")) - && provider.get("label") == Some(&serde_json::json!("Goose (Default)")) - }), - "expected Goose default provider sentinel" - ); - assert!( - providers - .iter() - .any(|provider| provider.get("id") == Some(&serde_json::json!("openai"))), - "expected at least one concrete provider from the goose registry" - ); + .expect("missing entries array"); + let openai = providers + .iter() + .find(|provider| provider.get("providerId") == Some(&serde_json::json!("openai"))) + .expect("expected openai inventory entry"); + + assert!(openai.get("providerName").is_some(), "missing providerName"); + assert!(openai.get("description").is_some(), "missing description"); + assert!(openai.get("defaultModel").is_some(), "missing defaultModel"); + assert!(openai.get("providerType").is_some(), "missing providerType"); + assert!(openai.get("configKeys").is_some(), "missing configKeys"); + assert!(openai.get("setupSteps").is_some(), "missing setupSteps"); }); } @@ -241,17 +239,20 @@ fn test_developer_fs_requests_use_acp_session_id() { ( "Use the read tool to read /tmp/test_acp_read.txt and output only its contents." .to_string(), - include_str!("test_data/openai_fs_read_tool_call.txt"), + include_str!("acp_test_data/openai_fs_read_tool_call.txt"), ), ( r#""content":"test-read-content-12345""#.into(), - include_str!("test_data/openai_fs_read_tool_result.txt"), + include_str!("acp_test_data/openai_fs_read_tool_result.txt"), ), ], Arc::new(IgnoreSessionId), ) .await; let config = TestConnectionConfig { + // gpt-5-nano routes to the Responses API; use a Chat Completions + // model so the canned SSE fixtures are parsed correctly. + current_model: "gpt-4.1".to_string(), read_text_file: Some(Arc::new(move |req| { *seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string()); Ok(sacp::schema::ReadTextFileResponse::new( diff --git a/crates/goose-acp/tests/fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs similarity index 99% rename from crates/goose-acp/tests/fixtures/mod.rs rename to crates/goose/tests/acp_fixtures/mod.rs index 4fc0c970a2cc..ab763edce984 100644 --- a/crates/goose-acp/tests/fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use fs_err as fs; +use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent}; pub use goose::acp::{map_permission_response, PermissionDecision}; use goose::builtin_extension::register_builtin_extensions; use goose::config::paths::Paths; @@ -11,7 +12,6 @@ use goose::providers::api_client::{ApiClient, AuthMethod as ApiAuthMethod}; use goose::providers::base::Provider; use goose::providers::openai::OpenAiProvider; use goose::session_context::SESSION_ID_HEADER; -use goose_acp::server::{serve, AcpProviderFactory, GooseAcpAgent}; use goose_test_support::{ExpectedSessionId, TEST_MODEL}; use sacp::schema::{ CreateTerminalResponse, KillTerminalResponse, ListSessionsResponse, McpServer, @@ -52,7 +52,7 @@ impl OpenAiFixture { .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "application/json") - .set_body_string(include_str!("../test_data/openai_models.json")), + .set_body_string(include_str!("../acp_test_data/openai_models.json")), ) .mount(&mock_server) .await; diff --git a/crates/goose-acp/tests/fixtures/provider.rs b/crates/goose/tests/acp_fixtures/provider.rs similarity index 100% rename from crates/goose-acp/tests/fixtures/provider.rs rename to crates/goose/tests/acp_fixtures/provider.rs diff --git a/crates/goose-acp/tests/fixtures/server.rs b/crates/goose/tests/acp_fixtures/server.rs similarity index 100% rename from crates/goose-acp/tests/fixtures/server.rs rename to crates/goose/tests/acp_fixtures/server.rs diff --git a/crates/goose-acp/tests/provider_test.rs b/crates/goose/tests/acp_provider_test.rs similarity index 99% rename from crates/goose-acp/tests/provider_test.rs rename to crates/goose/tests/acp_provider_test.rs index 9a1b1baf6872..5e2e65db3979 100644 --- a/crates/goose-acp/tests/provider_test.rs +++ b/crates/goose/tests/acp_provider_test.rs @@ -1,6 +1,7 @@ #![recursion_limit = "256"] #[allow(dead_code)] +#[path = "acp_common_tests/mod.rs"] mod common_tests; use common_tests::fixtures::provider::AcpProviderConnection; use common_tests::fixtures::run_test; diff --git a/crates/goose-acp/tests/server_test.rs b/crates/goose/tests/acp_server_test.rs similarity index 99% rename from crates/goose-acp/tests/server_test.rs rename to crates/goose/tests/acp_server_test.rs index a57aa40e6b6f..fd44ec32e0b4 100644 --- a/crates/goose-acp/tests/server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -1,4 +1,5 @@ #[allow(dead_code)] +#[path = "acp_common_tests/mod.rs"] mod common_tests; use common_tests::fixtures::run_test; use common_tests::fixtures::server::AcpServerConnection; diff --git a/crates/goose-acp/tests/test_data/openai_basic.txt b/crates/goose/tests/acp_test_data/openai_basic.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_basic.txt rename to crates/goose/tests/acp_test_data/openai_basic.txt diff --git a/crates/goose-acp/tests/test_data/openai_builtin_execute.txt b/crates/goose/tests/acp_test_data/openai_builtin_execute.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_builtin_execute.txt rename to crates/goose/tests/acp_test_data/openai_builtin_execute.txt diff --git a/crates/goose-acp/tests/test_data/openai_builtin_final.txt b/crates/goose/tests/acp_test_data/openai_builtin_final.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_builtin_final.txt rename to crates/goose/tests/acp_test_data/openai_builtin_final.txt diff --git a/crates/goose-acp/tests/test_data/openai_builtin_search.txt b/crates/goose/tests/acp_test_data/openai_builtin_search.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_builtin_search.txt rename to crates/goose/tests/acp_test_data/openai_builtin_search.txt diff --git a/crates/goose-acp/tests/test_data/openai_fs_read_tool_call.txt b/crates/goose/tests/acp_test_data/openai_fs_read_tool_call.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_fs_read_tool_call.txt rename to crates/goose/tests/acp_test_data/openai_fs_read_tool_call.txt diff --git a/crates/goose-acp/tests/test_data/openai_fs_read_tool_result.txt b/crates/goose/tests/acp_test_data/openai_fs_read_tool_result.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_fs_read_tool_result.txt rename to crates/goose/tests/acp_test_data/openai_fs_read_tool_result.txt diff --git a/crates/goose-acp/tests/test_data/openai_fs_write_tool_call.txt b/crates/goose/tests/acp_test_data/openai_fs_write_tool_call.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_fs_write_tool_call.txt rename to crates/goose/tests/acp_test_data/openai_fs_write_tool_call.txt diff --git a/crates/goose-acp/tests/test_data/openai_fs_write_tool_result.txt b/crates/goose/tests/acp_test_data/openai_fs_write_tool_result.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_fs_write_tool_result.txt rename to crates/goose/tests/acp_test_data/openai_fs_write_tool_result.txt diff --git a/crates/goose-acp/tests/test_data/openai_image_attachment.txt b/crates/goose/tests/acp_test_data/openai_image_attachment.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_image_attachment.txt rename to crates/goose/tests/acp_test_data/openai_image_attachment.txt diff --git a/crates/goose-acp/tests/test_data/openai_image_tool_call.txt b/crates/goose/tests/acp_test_data/openai_image_tool_call.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_image_tool_call.txt rename to crates/goose/tests/acp_test_data/openai_image_tool_call.txt diff --git a/crates/goose-acp/tests/test_data/openai_image_tool_result.txt b/crates/goose/tests/acp_test_data/openai_image_tool_result.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_image_tool_result.txt rename to crates/goose/tests/acp_test_data/openai_image_tool_result.txt diff --git a/crates/goose-acp/tests/test_data/openai_models.json b/crates/goose/tests/acp_test_data/openai_models.json similarity index 100% rename from crates/goose-acp/tests/test_data/openai_models.json rename to crates/goose/tests/acp_test_data/openai_models.json diff --git a/crates/goose-acp/tests/test_data/openai_shell_tool_call.txt b/crates/goose/tests/acp_test_data/openai_shell_tool_call.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_shell_tool_call.txt rename to crates/goose/tests/acp_test_data/openai_shell_tool_call.txt diff --git a/crates/goose-acp/tests/test_data/openai_shell_tool_result.txt b/crates/goose/tests/acp_test_data/openai_shell_tool_result.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_shell_tool_result.txt rename to crates/goose/tests/acp_test_data/openai_shell_tool_result.txt diff --git a/crates/goose-acp/tests/test_data/openai_tool_call.txt b/crates/goose/tests/acp_test_data/openai_tool_call.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_tool_call.txt rename to crates/goose/tests/acp_test_data/openai_tool_call.txt diff --git a/crates/goose-acp/tests/test_data/openai_tool_result.txt b/crates/goose/tests/acp_test_data/openai_tool_result.txt similarity index 100% rename from crates/goose-acp/tests/test_data/openai_tool_result.txt rename to crates/goose/tests/acp_test_data/openai_tool_result.txt diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index a267e7237307..96aac0ec71c3 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -374,6 +374,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -542,6 +543,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -867,6 +869,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } diff --git a/crates/goose/tests/compaction.rs b/crates/goose/tests/compaction.rs index 4c81e745e1ae..67b16b34a6a5 100644 --- a/crates/goose/tests/compaction.rs +++ b/crates/goose/tests/compaction.rs @@ -192,6 +192,7 @@ impl ProviderDef for MockCompactionProvider { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } diff --git a/crates/goose/tests/mcp_skills_e2e.rs b/crates/goose/tests/mcp_skills_e2e.rs new file mode 100644 index 000000000000..e01c7f01efaa --- /dev/null +++ b/crates/goose/tests/mcp_skills_e2e.rs @@ -0,0 +1,259 @@ +//! Mechanical end-to-end test for the skills-over-MCP extension. +//! +//! Spawns the test fork of `github-mcp-server` (the `add-agent-skills` +//! branch of `skills-over-mcp-ig/servers/github-mcp-server`) on an +//! ephemeral port, wires it into an `ExtensionManager`, and verifies: +//! +//! 1. `skill://index.json` is fetched at connect time and the cache is +//! populated with the `pull-requests` entry. +//! 2. `load_skill("pull-requests")` on the `SkillsClient` dispatches via +//! `ExtensionManager::read_resource` and returns the SKILL.md body +//! (which, in the test fork, contains the token `submit_pending`). +//! +//! No LLM involved; catches regressions in the resource-read plumbing in +//! sub-second wallclock. Gated on `GITHUB_TOKEN` and the server binary. +//! Marked `#[ignore]` so `cargo test` stays quiet. Run with +//! `cargo test --test mcp_skills_e2e -- --ignored --nocapture`. +//! +//! LLM-in-the-loop scenario runs live out-of-tree at +//! `skills-over-mcp-ig/clients/goose-harness/`. + +use std::collections::HashMap; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio_util::sync::CancellationToken; + +use goose::agents::extension::{Envs, ExtensionConfig, PlatformExtensionContext}; +use goose::agents::extension_manager::ExtensionManager; +use goose::agents::mcp_client::McpClientTrait; +use goose::skills::mcp_client::McpSkillEntry; +use goose::skills::SkillsClient; +use goose::agents::ToolCallContext; + +/// Pick an unused local TCP port by asking the OS for one. +fn free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); + listener.local_addr().unwrap().port() +} + +/// Poll TCP connect until the server is accepting connections or the +/// deadline passes. Returns Err with elapsed on timeout. +fn wait_for_port(port: u16, timeout: Duration) -> Result<(), Duration> { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + Err(start.elapsed()) +} + +/// Guarded subprocess — kills the server when dropped. +struct ServerHandle { + child: Child, +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Default path to the pre-built server binary in this workspace layout. +fn default_server_binary() -> PathBuf { + // `crates/goose/tests/...` — four parents up to `skills-over-mcp-ig/`. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = manifest_dir + .parent() // crates/ + .and_then(|p| p.parent()) // clients/goose/ + .and_then(|p| p.parent()) // clients/ + .and_then(|p| p.parent()) // skills-over-mcp-ig/ + .expect("workspace layout"); + root.join("servers") + .join("github-mcp-server") + .join(if cfg!(windows) { + "github-mcp-server.exe" + } else { + "github-mcp-server" + }) +} + +fn spawn_server(port: u16) -> Result { + let bin = std::env::var("GOOSE_E2E_SERVER_BIN") + .map(PathBuf::from) + .unwrap_or_else(|_| default_server_binary()); + + if !bin.exists() { + return Err(format!( + "server binary not found at {}. Set GOOSE_E2E_SERVER_BIN.", + bin.display() + )); + } + + let child = Command::new(&bin) + .arg("http") + .arg("--port") + .arg(port.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to spawn {}: {}", bin.display(), e))?; + + let handle = ServerHandle { child }; + + match wait_for_port(port, Duration::from_secs(10)) { + Ok(()) => Ok(handle), + Err(elapsed) => Err(format!("server did not bind :{} within {:?}", port, elapsed)), + } +} + +/// Skip the test with a printed reason when prerequisites are missing. +macro_rules! skip_if_missing { + ($var:expr, $reason:expr) => { + if std::env::var($var).is_err() { + eprintln!("SKIP: {} ({} not set)", $reason, $var); + return; + } + }; +} + +/// Builds the same `streamable_http` extension config the desktop UI would +/// construct for the test fork of `github-mcp-server`. `endpoint` is the +/// full MCP URI (e.g. `http://127.0.0.1:8082/mcp`). +fn github_extension_config(endpoint: &str, token: &str) -> ExtensionConfig { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), format!("Bearer {}", token)); + ExtensionConfig::StreamableHttp { + name: "github".to_string(), + description: "GitHub MCP (skills-over-MCP test fork)".to_string(), + uri: endpoint.to_string(), + envs: Envs::default(), + env_keys: Vec::new(), + headers, + timeout: Some(30), + socket: None, + bundled: None, + available_tools: Vec::new(), + } +} + +#[tokio::test] +#[ignore] +async fn mcp_skills_discovery_and_load_against_real_server() { + skip_if_missing!( + "GITHUB_TOKEN", + "needs github PAT to auth against github-mcp-server" + ); + let token = std::env::var("GITHUB_TOKEN").unwrap(); + + let port = free_port(); + let server = match spawn_server(port) { + Ok(s) => s, + Err(e) => { + eprintln!("SKIP: {}", e); + return; + } + }; + eprintln!("[e2e] spawned github-mcp-server on :{}", port); + + // Construct an ExtensionManager the way production code does. + let temp = tempfile::tempdir().expect("tempdir"); + let mgr = Arc::new(ExtensionManager::new_without_provider( + temp.path().to_path_buf(), + )); + + // Pass the session id we'll use later for load_skill — the MCP + // client locks itself to a single session on its first request. + mgr.add_extension( + github_extension_config(&format!("http://127.0.0.1:{}/mcp", port), &token), + None, + None, + Some("e2e-session"), + ) + .await + .expect("add_extension should succeed"); + + // Phase 1: mechanical check — cache populated? + let cached: Vec = mgr.aggregated_mcp_skills().await; + eprintln!("[e2e] cached mcp skills: {:#?}", cached); + let pr = cached + .iter() + .find(|e| e.name == "pull-requests") + .expect("pull-requests skill should have been discovered from skill://index.json"); + assert_eq!( + pr.uri, "skill://pull-requests/SKILL.md", + "server should advertise the canonical SKILL.md URI" + ); + assert_eq!( + pr.base_uri, "skill://pull-requests/", + "base_uri should strip SKILL.md" + ); + + // Phase 2: mechanical check — load_skill dispatches through resources/read? + // Construct a SkillsClient wired to the manager (same shape as the + // platform extension does in production). + let session_manager = Arc::new(goose::session::SessionManager::instance()); + let ctx = PlatformExtensionContext { + extension_manager: Some(Arc::downgrade(&mgr)), + session_manager, + session: Some(Arc::new(goose::session::Session { + working_dir: temp.path().to_path_buf(), + ..goose::session::Session::default() + })), + }; + let skills_client = SkillsClient::new(ctx).expect("SkillsClient::new"); + + let tool_ctx = ToolCallContext::new("e2e-session".to_string(), None, None); + let args: rmcp::model::JsonObject = + serde_json::from_value(serde_json::json!({"name": "pull-requests"})).unwrap(); + let result = skills_client + .call_tool(&tool_ctx, "load_skill", Some(args), CancellationToken::new()) + .await + .expect("call_tool should not propagate transport error"); + + let text = match &result.content[0].raw { + rmcp::model::RawContent::Text(t) => t.text.clone(), + _ => panic!("expected text content"), + }; + eprintln!("[e2e] load_skill output (truncated):\n{:.400}", text); + + assert!( + !result.is_error.unwrap_or(false), + "load_skill should succeed; got: {}", + text + ); + assert!( + text.contains("pull-requests"), + "framing should contain the skill name; got: {}", + text + ); + // The test fork ships a SKILL.md whose body prescribes the + // pending-review workflow. This string only appears if the body was + // actually read from the server. + assert!( + text.contains("submit_pending") || text.contains("add_comment_to_pending_review"), + "skill body should contain the prescribed workflow tokens; got: {}", + text + ); + + // Dynamic instructions should surface the skill too. + let dynamic = skills_client + .get_dynamic_instructions("e2e-session") + .await + .expect("get_dynamic_instructions should return Some"); + eprintln!("[e2e] dynamic instructions:\n{}", dynamic); + assert!( + dynamic.contains("pull-requests") && dynamic.contains("github"), + "dynamic instructions should list the skill with its origin server" + ); + + drop(server); // explicit kill — makes intent obvious + eprintln!("[e2e] PASS"); +} diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index f6ad47cbe34b..f36471e90186 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -302,7 +302,7 @@ impl ProviderFixture { let info = self .agent .extension_manager - .get_extensions_info(std::path::Path::new(".")) + .get_extensions_info(&self.session_id, std::path::Path::new(".")) .await; let system = PromptManager::new() .builder() @@ -888,7 +888,7 @@ async fn test_codex_provider() -> Result<()> { .await } -// Requires: npm install -g @zed-industries/claude-agent-acp +// Requires: npm install -g @agentclientprotocol/claude-agent-acp #[tokio::test] async fn test_claude_acp_provider() -> Result<()> { ProviderTestConfig::with_agentic_provider("claude-acp", ACP_CURRENT_MODEL, "claude-agent-acp") diff --git a/crates/goose/tests/session_id_propagation_test.rs b/crates/goose/tests/session_id_propagation_test.rs index fe902dc9d1b8..be26868475d1 100644 --- a/crates/goose/tests/session_id_propagation_test.rs +++ b/crates/goose/tests/session_id_propagation_test.rs @@ -48,12 +48,13 @@ fn create_test_provider(mock_server_url: &str) -> Box { async fn setup_mock_server() -> (MockServer, HeaderCapture, Box) { let mock_server = MockServer::start().await; let capture = HeaderCapture::new(); - let capture_clone = capture.clone(); + let chat_capture = capture.clone(); + let responses_capture = capture.clone(); Mock::given(method("POST")) .and(path("/v1/chat/completions")) .respond_with(move |req: &Request| { - capture_clone.capture_session_header(req); + chat_capture.capture_session_header(req); // Return SSE streaming format let sse_response = format!( "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", @@ -85,6 +86,57 @@ async fn setup_mock_server() -> (MockServer, HeaderCapture, Box) { .mount(&mock_server) .await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(move |req: &Request| { + responses_capture.capture_session_header(req); + let sse_response = format!( + "data: {}\n\ndata: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + json!({ + "type": "response.created", + "sequence_number": 1, + "response": { + "id": "resp_test", + "object": "response", + "created_at": 1755133833, + "status": "in_progress", + "model": "gpt-5-nano", + "output": [] + } + }), + json!({ + "type": "response.output_text.delta", + "sequence_number": 2, + "item_id": "msg_test", + "output_index": 0, + "content_index": 0, + "delta": "Hi there! How can I help you today?" + }), + json!({ + "type": "response.completed", + "sequence_number": 3, + "response": { + "id": "resp_test", + "object": "response", + "created_at": 1755133833, + "status": "completed", + "model": "gpt-5-nano", + "output": [], + "usage": { + "input_tokens": 8, + "output_tokens": 10, + "total_tokens": 18 + } + } + }) + ); + ResponseTemplate::new(200) + .set_body_string(sse_response) + .insert_header("content-type", "text/event-stream") + }) + .mount(&mock_server) + .await; + let provider = create_test_provider(&mock_server.uri()); (mock_server, capture, provider) } diff --git a/documentation/blog/2026-04-20-mesh-llm/index.md b/documentation/blog/2026-04-20-mesh-llm/index.md new file mode 100644 index 000000000000..14f2c8bec7ab --- /dev/null +++ b/documentation/blog/2026-04-20-mesh-llm/index.md @@ -0,0 +1,29 @@ +--- +title: "Mesh LLM in goose: routing across models" +description: "Mesh LLM is now available as a provider setting in goose." +authors: + - mic +--- + +Quick note: [Mesh LLM](https://github.com/Mesh-LLM/mesh-llm/) is now in goose as an option for accessing and sharing (open) LLMs with friends and family. + +It uses the same llama.cpp infra as local mode to run models, with a twist. + + + +## What is Mesh LLM? + +Mesh LLM is an associated project we're trying out that lets people connect their compute capacity (which may just be a laptop) peer-to-peer, so they can access models they may not otherwise be able to self-host. + +There is a demo public "mesh" which at any point has some capacity in it, but you can also make your own private networks and pool compute together. The mesh will try to work out the best places to run models (downloading them as needed) and can even split the compute in various ways. + +This is a pretty early-stage project, so we'd love any feedback on it. + +Check out [the project docs](https://docs.anarchai.org/) and the [live public mesh](https://meshllm.cloud/dashboard). + + + + + + + diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index a1c08f208feb..dfa0632cd52b 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -67,7 +67,7 @@ goose supports [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) a | Provider | Description | Requirements | |-----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Claude ACP](https://github.com/zed-industries/claude-agent-acp) (`claude-acp`) | Uses Claude Code via ACP. Passes goose extensions to the agent as MCP servers. | `npm install -g @zed-industries/claude-agent-acp`, active Claude Code subscription | +| [Claude ACP](https://github.com/agentclientprotocol/claude-agent-acp) (`claude-acp`) | Uses Claude Code via ACP. Passes goose extensions to the agent as MCP servers. | `npm install -g @agentclientprotocol/claude-agent-acp`, active Claude Code subscription | | [Codex ACP](https://github.com/zed-industries/codex-acp) (`codex-acp`) | Uses OpenAI Codex via ACP. Passes goose extensions to the agent as MCP servers. | `npm install -g @zed-industries/codex-acp`, active ChatGPT Plus/Pro subscription | :::tip ACP Providers diff --git a/documentation/docs/guides/acp-clients.md b/documentation/docs/guides/acp-clients.md index 397e9fe59dd0..ffa6518c8799 100644 --- a/documentation/docs/guides/acp-clients.md +++ b/documentation/docs/guides/acp-clients.md @@ -177,7 +177,7 @@ npm install **Option 1: Auto-launch server (recommended)** -The TUI will automatically start the goose-acp-server if you have it installed: +The TUI will automatically start the goose acp server if you have it installed: ```bash npm start @@ -191,7 +191,7 @@ For servers that support the draft standard ACP over Streamable HTTP https://git npm start -- --server http://HOST:PORT # example server -cargo run -p goose-acp --bin goose-acp-server +cargo run -p goose-cli --bin goose -- serve ``` ### Single Prompt Mode diff --git a/documentation/docs/guides/acp-providers.md b/documentation/docs/guides/acp-providers.md index 8ce940c4760a..27b56b580982 100644 --- a/documentation/docs/guides/acp-providers.md +++ b/documentation/docs/guides/acp-providers.md @@ -34,7 +34,7 @@ Wraps [amp-acp](https://www.npmjs.com/package/amp-acp), an ACP adapter for [Amp] ### Claude ACP -Wraps [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp), an ACP adapter for Anthropic's Claude Code. Uses the same Claude subscription as the deprecated `claude-code` CLI provider. +Wraps [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp), an ACP adapter for Anthropic's Claude Code. Uses the same Claude subscription as the deprecated `claude-code` CLI provider. **Requirements:** - Node.js and npm @@ -93,7 +93,7 @@ Wraps `pi-acp`, an ACP adapter for Pi. Uses your existing Pi installation. 1. **Install the ACP adapter** ```bash - npm install -g @zed-industries/claude-agent-acp + npm install -g @agentclientprotocol/claude-agent-acp ``` 2. **Authenticate with Claude** @@ -235,7 +235,7 @@ GOOSE_PROVIDER=codex-acp goose run \ | `approve` | `default` | Prompts for all permission-required operations | | `chat` | `plan` | Planning only, no tool execution | -See [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp) for session mode details. +See [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) for session mode details. ### Codex ACP Configuration diff --git a/documentation/docs/guides/context-engineering/using-skills.md b/documentation/docs/guides/context-engineering/using-skills.md index 6c5f80ea7b05..948da14ea7db 100644 --- a/documentation/docs/guides/context-engineering/using-skills.md +++ b/documentation/docs/guides/context-engineering/using-skills.md @@ -17,7 +17,7 @@ When a session starts, goose adds any skills that it discovers to its instructio - "Follow the new-service skill to set up the auth service" - "Apply the deployment skill" -You can also ask goose what skills are available. +You can also ask goose what skills are available, or use the CLI `/skills` command to list available skills and load one or more by name (e.g. `/skills code-review edge-case-finder`). :::info Claude Compatibility goose skills are compatible with Claude Desktop and other [agents that support Agent Skills](https://agentskills.io/home#adoption). diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index 1f3144af577d..95f4a3a8e469 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -628,6 +628,7 @@ Once you're in an interactive session (via `goose session` or `goose run --inter - **`/recipe [filepath]`** - Generate a recipe from the current conversation and save it to the specified filepath (must end with .yaml). If no filepath is provided, it will be saved to ./recipe.yaml - **`/compact`** - Compact and summarize the current conversation to reduce context length while preserving key information - **`/r`** - Toggle full tool output display (show complete tool parameters without truncation) +- **`/skills`** - List available skills - **`/t`** - Toggle between `light`, `dark`, and `ansi` themes. [More info](#themes). - **`/t `** - Set theme directly (light, dark, ansi) diff --git a/documentation/docs/mcp/exa-mcp.md b/documentation/docs/mcp/exa-mcp.md new file mode 100644 index 000000000000..60855427a3ff --- /dev/null +++ b/documentation/docs/mcp/exa-mcp.md @@ -0,0 +1,117 @@ +--- +title: Exa Search Extension +description: Add Exa MCP Server as a goose Extension +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller'; +import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; + +This tutorial covers how to add the [Exa MCP Server](https://github.com/exa-labs/exa-mcp-server) as a goose extension to enable AI-powered web search functionality. + +:::tip Quick Install + + + [Launch the installer](goose://extension?cmd=npx&arg=-y&arg=exa-mcp-server&id=exa&name=Exa%20Search&description=AI-powered%20web%20search&env=EXA_API_KEY%3DExa%20API%20Key) + + + **Command** + ```sh + npx -y exa-mcp-server + ``` + + + **Environment Variable** + ``` + EXA_API_KEY: + ``` +::: + +## Configuration + +:::info +Note that you'll need [Node.js](https://nodejs.org/) installed on your system to run this command, as it uses `npx`. +::: + + + + + + + + Obtain your Exa API Key and paste it in. You can get your API key by signing up at exa.ai and navigating to the API keys page in the dashboard. + + } + /> + + + +## Example Usage + +The Exa MCP server enables AI-powered web search in your goose interactions. Exa offers advanced search capabilities including: + +1. Neural, keyword, and auto search modes +2. Content retrieval with full text, highlights, or summaries +3. Category filtering (news, research papers, companies, and more) +4. Domain and date range filtering + +### goose Prompt + +``` +Find recent research papers about large language model reasoning capabilities and summarize the key findings. +``` + +### goose Output + +``` +I'll use Exa to search for recent research papers on LLM reasoning. + +─── web_search_exa | exa ────────────────────────── +query: large language model reasoning capabilities research paper +category: research paper + + +Here are the key findings from recent research on LLM reasoning: + +1. Chain-of-Thought Reasoning: +- Recent papers show that explicit chain-of-thought prompting significantly improves performance on multi-step reasoning tasks +- Models trained with reasoning traces demonstrate better generalization to novel problem types + +2. Mathematical and Logical Reasoning: +- New benchmarks reveal that while LLMs have improved at arithmetic and formal logic, they still struggle with compositional reasoning +- Hybrid approaches combining neural methods with symbolic solvers show promising results + +3. Commonsense Reasoning: +- Studies demonstrate that scaling model size continues to improve commonsense reasoning benchmarks +- However, models still fail on tasks requiring physical intuition or temporal reasoning about everyday events + +4. Limitations and Future Directions: +- Several papers identify "reasoning shortcuts" where models exploit dataset patterns rather than performing genuine inference +- Research into retrieval-augmented reasoning shows potential for grounding model outputs in verified facts + +Would you like me to search for more details on any of these topics? +``` + +:::tip +Exa supports multiple search types including neural search for semantic queries and auto mode that intelligently combines search strategies. You can also filter results by category, domain, and date range for more targeted research. +::: diff --git a/documentation/static/servers.json b/documentation/static/servers.json index 5e5632c6376b..59cdd1d385dc 100644 --- a/documentation/static/servers.json +++ b/documentation/static/servers.json @@ -280,6 +280,23 @@ } ] }, + { + "id": "exa", + "name": "Exa Search", + "description": "AI-powered web search with neural and keyword capabilities", + "command": "npx -y exa-mcp-server", + "link": "https://github.com/exa-labs/exa-mcp-server", + "installation_notes": "Install using npx package manager. Requires Exa API key.", + "is_builtin": false, + "endorsed": false, + "environmentVariables": [ + { + "name": "EXA_API_KEY", + "description": "API key for Exa web search service", + "required": true + } + ] + }, { "id": "excalidraw-mcp-app", "name": "Excalidraw", diff --git a/evals/open-model-gym/mcp-harness/package-lock.json b/evals/open-model-gym/mcp-harness/package-lock.json index 367849ea4433..ce355ab5f29c 100644 --- a/evals/open-model-gym/mcp-harness/package-lock.json +++ b/evals/open-model-gym/mcp-harness/package-lock.json @@ -582,9 +582,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/evals/open-model-gym/suite/scenarios/file-editing.yaml b/evals/open-model-gym/suite/scenarios/file-editing.yaml index d3ba73202fc2..5eb1d818ebef 100644 --- a/evals/open-model-gym/suite/scenarios/file-editing.yaml +++ b/evals/open-model-gym/suite/scenarios/file-editing.yaml @@ -15,6 +15,8 @@ setup: version = "0.1.0" edition = "2021" + [workspace] + src/main.rs: | mod models; mod utils; diff --git a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml b/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml index 5e3bc0475158..03798a85ca6b 100644 --- a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml +++ b/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml @@ -12,6 +12,8 @@ setup: version = "0.1.0" edition = "2021" + [workspace] + src/main.rs: | mod models; diff --git a/scripts/test_providers.sh b/scripts/test_providers.sh deleted file mode 100755 index b6c28b8d445e..000000000000 --- a/scripts/test_providers.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/bin/bash - -LIB_DIR="$(cd "$(dirname "$0")" && pwd)" -source "$LIB_DIR/test_providers_lib.sh" - -echo "Mode: normal (direct tool calls)" -echo "" - -GOOSE_BIN=$(build_goose) -BUILTINS="developer" - -mkdir -p target -TEST_CONTENT="test-content-abc123" -TEST_FILE="./target/test-content.txt" -echo "$TEST_CONTENT" > "$TEST_FILE" - -run_test() { - local provider="$1" model="$2" result_file="$3" output_file="$4" - local testdir=$(mktemp -d) - - local prompt - if is_agentic_provider "$provider"; then - cp "$TEST_FILE" "$testdir/test-content.txt" - prompt="read ./test-content.txt and output its contents exactly" - else - # Write two files with unique random tokens. Validation checks that the shell - # tool was used and that both tokens appear in the output, proving the model - # actually read the files (random tokens can't be guessed or hallucinated). - local token_a="smoke-alpha-$RANDOM" - local token_b="smoke-bravo-$RANDOM" - echo "$token_a" > "$testdir/part-a.txt" - echo "$token_b" > "$testdir/part-b.txt" - # Store tokens so validation can check them - echo "$token_a" > "$testdir/.token_a" - echo "$token_b" > "$testdir/.token_b" - prompt="Use the shell tool to cat ./part-a.txt and ./part-b.txt, then reply with ONLY the contents of both files, one per line, nothing else." - fi - - ( - export GOOSE_PROVIDER="$provider" - export GOOSE_MODEL="$model" - cd "$testdir" && "$GOOSE_BIN" run --text "$prompt" --with-builtin "$BUILTINS" 2>&1 - ) > "$output_file" 2>&1 - - if is_agentic_provider "$provider"; then - if grep -qi "$TEST_CONTENT" "$output_file"; then - echo "success|test content found by model" > "$result_file" - else - echo "failure|test content not found by model" > "$result_file" - fi - else - local token_a token_b - token_a=$(cat "$testdir/.token_a") - token_b=$(cat "$testdir/.token_b") - if ! grep -qE "(shell \| developer)|(▸.*shell)" "$output_file"; then - echo "failure|model did not use shell tool" > "$result_file" - elif ! grep -q "$token_a" "$output_file"; then - echo "failure|model did not return contents of part-a.txt ($token_a)" > "$result_file" - elif ! grep -q "$token_b" "$output_file"; then - echo "failure|model did not return contents of part-b.txt ($token_b)" > "$result_file" - else - echo "success|model read and returned both file contents" > "$result_file" - fi - fi - - rm -rf "$testdir" -} - -build_test_cases -run_test_cases run_test -report_results diff --git a/scripts/test_providers_code_exec.sh b/scripts/test_providers_code_exec.sh deleted file mode 100755 index c9d720d202a0..000000000000 --- a/scripts/test_providers_code_exec.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# Provider smoke tests - code execution mode (JS batching) - -LIB_DIR="$(cd "$(dirname "$0")" && pwd)" -source "$LIB_DIR/test_providers_lib.sh" - -echo "Mode: code_execution (JS batching)" -echo "" - -# --- Setup --- - -GOOSE_BIN=$(build_goose) -BUILTINS="memory,code_execution" - -# --- Test case --- - -run_test() { - local provider="$1" model="$2" result_file="$3" output_file="$4" - local testdir=$(mktemp -d) - - local prompt="Store a memory with category 'test' and data 'hello world', then retrieve all memories from category 'test'." - - # Run goose - ( - export GOOSE_PROVIDER="$provider" - export GOOSE_MODEL="$model" - cd "$testdir" && "$GOOSE_BIN" run --text "$prompt" --with-builtin "$BUILTINS" 2>&1 - ) > "$output_file" 2>&1 - - # Matches: "execute_typescript | code_execution", "get_function_details | code_execution", - # "tool call | execute", "tool calls | execute" (old format) - # "▸ execute N tool call" (new format with tool_graph) - # "▸ execute_typescript" (plain tool name in output) - if grep -qE "(execute_typescript \| code_execution)|(get_function_details \| code_execution)|(tool calls? \| execute)|(▸.*execute.*tool call)|(▸ execute_typescript)" "$output_file"; then - echo "success|code_execution tool called" > "$result_file" - else - echo "failure|no code_execution tool calls found" > "$result_file" - fi - - rm -rf "$testdir" -} - -build_test_cases --skip-agentic -run_test_cases run_test -report_results diff --git a/scripts/test_providers_lib.sh b/scripts/test_providers_lib.sh deleted file mode 100755 index 0ef52f12d11c..000000000000 --- a/scripts/test_providers_lib.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/bin/bash - -PROVIDER_CONFIG=" -openrouter -> google/gemini-2.5-pro|anthropic/claude-sonnet-4.5|qwen/qwen3-coder:exacto|z-ai/glm-4.6:exacto|nvidia/nemotron-3-nano-30b-a3b -xai -> grok-3 -openai -> gpt-4o|gpt-4o-mini|gpt-3.5-turbo|gpt-5 -anthropic -> claude-sonnet-4-5-20250929|claude-opus-4-5-20251101 -google -> gemini-2.5-pro|gemini-2.5-flash|gemini-3-pro-preview|gemini-3-flash-preview -tetrate -> claude-sonnet-4-20250514 -databricks -> databricks-claude-sonnet-4|gemini-2-5-flash|gpt-4o -azure_openai -> ${AZURE_OPENAI_DEPLOYMENT_NAME} -aws_bedrock -> us.anthropic.claude-sonnet-4-5-20250929-v1:0 -gcp_vertex_ai -> gemini-2.5-pro -snowflake -> claude-sonnet-4-5 -venice -> llama-3.3-70b -litellm -> gpt-4o-mini -sagemaker_tgi -> sagemaker-tgi-endpoint -github_copilot -> gpt-4.1 -chatgpt_codex -> gpt-5.1-codex -claude-code -> default -codex -> gpt-5.2-codex -gemini-cli -> gemini-2.5-pro -cursor-agent -> auto -ollama -> qwen3 -" - -# Flaky models allowed to fail without blocking PRs. -ALLOWED_FAILURES=( - "google:gemini-2.5-flash" - "google:gemini-3-pro-preview" - "openrouter:nvidia/nemotron-3-nano-30b-a3b" - "openrouter:qwen/qwen3-coder:exacto" - "openai:gpt-3.5-turbo" -) - -AGENTIC_PROVIDERS=("claude-code" "codex" "gemini-cli" "cursor-agent") - -if [ -f .env ]; then - export $(grep -v '^#' .env | xargs) -fi - -build_goose() { - if [ -z "$SKIP_BUILD" ]; then - echo "Building goose..." >&2 - cargo build --bin goose >&2 - echo "" >&2 - else - echo "Skipping build (SKIP_BUILD is set)..." >&2 - echo "" >&2 - fi - - echo "$(pwd)/target/debug/goose" -} - -has_env() { [ -n "${!1}" ]; } -has_cmd() { command -v "$1" &>/dev/null; } -has_file() { [ -f "$1" ]; } - -is_provider_available() { - case "$1" in - openrouter) has_env OPENROUTER_API_KEY ;; - xai) has_env XAI_API_KEY ;; - openai) has_env OPENAI_API_KEY ;; - anthropic) has_env ANTHROPIC_API_KEY ;; - google) has_env GOOGLE_API_KEY ;; - tetrate) has_env TETRATE_API_KEY ;; - databricks) has_env DATABRICKS_HOST && has_env DATABRICKS_TOKEN ;; - azure_openai) has_env AZURE_OPENAI_ENDPOINT && has_env AZURE_OPENAI_DEPLOYMENT_NAME ;; - aws_bedrock) has_env AWS_REGION && { has_env AWS_PROFILE || has_env AWS_ACCESS_KEY_ID; } ;; - gcp_vertex_ai) has_env GCP_PROJECT_ID ;; - snowflake) has_env SNOWFLAKE_HOST && has_env SNOWFLAKE_TOKEN ;; - venice) has_env VENICE_API_KEY ;; - litellm) has_env LITELLM_API_KEY ;; - sagemaker_tgi) has_env SAGEMAKER_ENDPOINT_NAME && has_env AWS_REGION ;; - github_copilot) has_env GITHUB_COPILOT_TOKEN || has_file "$HOME/.config/goose/github_copilot_token.json" ;; - chatgpt_codex) has_env CHATGPT_CODEX_TOKEN || has_file "$HOME/.config/goose/chatgpt_codex_token.json" ;; - ollama) has_env OLLAMA_HOST || has_cmd ollama ;; - claude-code) has_cmd claude ;; - codex) has_cmd codex ;; - gemini-cli) has_cmd gemini ;; - cursor-agent) has_cmd cursor-agent ;; - *) return 0 ;; - esac -} - -is_allowed_failure() { - local key="${1}:${2}" - for allowed in "${ALLOWED_FAILURES[@]}"; do - [ "$allowed" = "$key" ] && return 0 - done - return 1 -} - -should_skip_provider() { - [ -z "$SKIP_PROVIDERS" ] && return 1 - IFS=',' read -ra SKIP_LIST <<< "$SKIP_PROVIDERS" - for skip in "${SKIP_LIST[@]}"; do - skip=$(echo "$skip" | xargs) - [ "$skip" = "$1" ] && return 0 - done - return 1 -} - -is_agentic_provider() { - for agentic in "${AGENTIC_PROVIDERS[@]}"; do - [ "$agentic" = "$1" ] && return 0 - done - return 1 -} - -# build_test_cases [--skip-agentic] -build_test_cases() { - local skip_agentic=false - [ "$1" = "--skip-agentic" ] && skip_agentic=true - - local providers=() - while IFS= read -r line; do - [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue - local provider="${line%% -> *}" - if is_provider_available "$provider"; then - providers+=("$line") - echo "✓ Including $provider" - else - echo "⚠️ Skipping $provider (prerequisites not met)" - fi - done <<< "$PROVIDER_CONFIG" - echo "" - - TEST_CASES=() - local job_index=0 - for provider_config in "${providers[@]}"; do - local provider="${provider_config%% -> *}" - local models_str="${provider_config#* -> }" - - if should_skip_provider "$provider"; then - echo "⊘ Skipping provider: ${provider} (SKIP_PROVIDERS)" - continue - fi - - if [ "$skip_agentic" = true ] && is_agentic_provider "$provider"; then - echo "⊘ Skipping agentic provider: ${provider}" - continue - fi - - IFS='|' read -ra models <<< "$models_str" - for model in "${models[@]}"; do - TEST_CASES+=("$provider|$model|$job_index") - ((job_index++)) - done - done -} - -# run_test_cases -run_test_cases() { - local test_fn="$1" - - RESULTS_DIR=$(mktemp -d) - trap 'if [ -n "${RESULTS_DIR:-}" ]; then rm -rf -- "$RESULTS_DIR"; fi; if [ -n "${CLEANUP_DIR:-}" ]; then rm -rf -- "$CLEANUP_DIR"; fi' EXIT - MAX_PARALLEL=${MAX_PARALLEL:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 8)} - echo "Running ${#TEST_CASES[@]} tests (max $MAX_PARALLEL parallel)" - echo "" - - local running=0 - for ((i=0; i<${#TEST_CASES[@]}; i++)); do - IFS='|' read -r provider model idx <<< "${TEST_CASES[$i]}" - - if [ $i -eq 0 ]; then - # First test runs sequentially to catch early failures - "$test_fn" "$provider" "$model" "$RESULTS_DIR/result_$idx" "$RESULTS_DIR/output_$idx" - else - "$test_fn" "$provider" "$model" "$RESULTS_DIR/result_$idx" "$RESULTS_DIR/output_$idx" & - ((running++)) - if [ $running -ge $MAX_PARALLEL ]; then - wait -n 2>/dev/null || wait - ((running--)) - fi - fi - done - wait -} - -report_results() { - echo "" - echo "=== Test Results ===" - echo "" - - RESULTS=() - HARD_FAILURES=() - - for job in "${TEST_CASES[@]}"; do - IFS='|' read -r provider model idx <<< "$job" - - echo "Provider: $provider" - echo "Model: $model" - echo "" - cat "$RESULTS_DIR/output_$idx" - echo "" - - local result_line="" - [ -f "$RESULTS_DIR/result_$idx" ] && result_line=$(cat "$RESULTS_DIR/result_$idx") - local status="${result_line%%|*}" - local msg="${result_line#*|}" - - if [ "$status" = "success" ]; then - echo "✓ SUCCESS: $msg" - RESULTS+=("✓ ${provider}: ${model}") - else - if is_allowed_failure "$provider" "$model"; then - echo "⚠ FLAKY: $msg" - RESULTS+=("⚠ ${provider}: ${model} (flaky)") - else - echo "✗ FAILED: $msg" - RESULTS+=("✗ ${provider}: ${model}") - HARD_FAILURES+=("${provider}: ${model}") - fi - fi - echo "---" - done - - echo "" - echo "=== Test Summary ===" - for result in "${RESULTS[@]}"; do - echo "$result" - done - - if [ ${#HARD_FAILURES[@]} -gt 0 ]; then - echo "" - echo "Hard failures (${#HARD_FAILURES[@]}):" - for failure in "${HARD_FAILURES[@]}"; do - echo " - $failure" - done - echo "" - echo "Some tests failed!" - exit 1 - else - if echo "${RESULTS[@]}" | grep -q "⚠"; then - echo "" - echo "All required tests passed! (some flaky tests failed but are allowed)" - else - echo "" - echo "All tests passed!" - fi - fi -} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 7260dfbeb5c4..d47403ec9b38 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.31.0" + "version": "1.32.0" }, "paths": { "/action-required/tool-confirmation": { @@ -4165,7 +4165,8 @@ "enum": [ "Builtin", "Recipe", - "Skill" + "Skill", + "Agent" ] }, "ConfigKey": { @@ -4642,6 +4643,10 @@ }, "nullable": true }, + "model_doc_link": { + "type": "string", + "nullable": true + }, "models": { "type": "array", "items": { @@ -4654,6 +4659,12 @@ "requires_auth": { "type": "boolean" }, + "setup_steps": { + "type": "array", + "items": { + "type": "string" + } + }, "skip_canonical_filtering": { "type": "boolean" }, @@ -6864,6 +6875,11 @@ "type": "string", "description": "Link to the docs where models can be found" }, + "model_selection_hint": { + "type": "string", + "description": "Hint shown in the model picker when this provider manages its own model selection.", + "nullable": true + }, "name": { "type": "string", "description": "The unique identifier for this provider" diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 991667e80caf..0988f399e79f 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "Goose", - "version": "1.31.0", + "version": "1.32.0", "description": "Goose App", "engines": { "node": "^24.10.0", @@ -35,6 +35,9 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:integration:goosed": "vitest run --config vitest.integration.config.ts tests/integration/goosed.test.ts", + "test:integration:providers": "vitest run --config vitest.integration.config.ts tests/integration/test_providers.test.ts", + "test:integration:providers-code-exec": "vitest run --config vitest.integration.config.ts tests/integration/test_providers_code_exec.test.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:integration:debug": "DEBUG=1 vitest run --config vitest.integration.config.ts", "i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/messages/en.json --flatten && pnpm run i18n:compile", diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index d594441edae2..f45e44588cab 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -87,6 +87,8 @@ vi.mock('./components/ModelAndProviderContext', () => ({ provider: null, model: null, getCurrentModelAndProvider: vi.fn(), + getFallbackModelAndProvider: vi.fn().mockResolvedValue({ provider: '', model: '' }), + refreshCurrentModelAndProvider: vi.fn().mockResolvedValue(undefined), setCurrentModelAndProvider: vi.fn(), }), })); @@ -205,8 +207,6 @@ describe('App Component - Brand New State', () => { window.location.hash = ''; window.location.search = ''; window.location.pathname = '/'; - window.sessionStorage?.clear?.(); - window.localStorage?.clear?.(); }); afterEach(() => { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a9e8d80bf302..8a559e74893f 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -80,7 +80,7 @@ export type CheckProviderRequest = { provider: string; }; -export type CommandType = 'Builtin' | 'Recipe' | 'Skill'; +export type CommandType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent'; /** * Configuration key metadata for provider setup @@ -220,9 +220,11 @@ export type DeclarativeProviderConfig = { headers?: { [key: string]: string; } | null; + model_doc_link?: string | null; models: Array; name: string; requires_auth?: boolean; + setup_steps?: Array; skip_canonical_filtering?: boolean; supports_streaming?: boolean | null; timeout_seconds?: number | null; @@ -970,6 +972,10 @@ export type ProviderMetadata = { * Link to the docs where models can be found */ model_doc_link: string; + /** + * Hint shown in the model picker when this provider manages its own model selection. + */ + model_selection_hint?: string | null; /** * The unique identifier for this provider */ diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index c4987814cbf5..f676deb1405e 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -178,6 +178,7 @@ export const ConfigProvider: React.FC = ({ children }) => { try { const response = await providers(); const providersData = response.data || []; + providersListRef.current = providersData; setProvidersList(providersData); return providersData; } catch (error) { @@ -199,6 +200,7 @@ export const ConfigProvider: React.FC = ({ children }) => { try { const providersResponse = await providers(); const providersData = providersResponse.data || []; + providersListRef.current = providersData; setProvidersList(providersData); } catch (error) { console.error('Failed to load providers:', error); diff --git a/ui/desktop/src/components/ItemIcon.tsx b/ui/desktop/src/components/ItemIcon.tsx index deae40918e8f..1282f03eb2f8 100644 --- a/ui/desktop/src/components/ItemIcon.tsx +++ b/ui/desktop/src/components/ItemIcon.tsx @@ -36,6 +36,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => { return { Icon: BookOpen, color: '#10b981' }; // Green case 'Skill': return { Icon: Sparkles, color: '#8b5cf6' }; // Purple + case 'Agent': + return { Icon: Terminal, color: '#f59e0b' }; // Amber case 'Directory': return { Icon: Folder, color: '#f59e0b' }; // Amber default: { diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 0ca65ba100f5..65c78c3bc21d 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -38,11 +38,12 @@ const i18n = defineMessages({ type DisplayItemType = CommandType | 'Directory' | 'File'; const typeOrder: Record = { - Directory: 0, - File: 1, - Builtin: 2, - Skill: 3, - Recipe: 4, + Agent: 0, + Directory: 1, + File: 2, + Builtin: 3, + Skill: 4, + Recipe: 5, }; export interface DisplayItem { @@ -426,7 +427,9 @@ const MentionPopover = forwardRef< ); let finalScore = bestMatch.score; - if (finalScore > 0 && currentWorkingDir) { + if (finalScore > 0 && file.itemType === 'Agent') { + finalScore += 100; + } else if (finalScore > 0 && currentWorkingDir) { const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1; finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0; } @@ -449,6 +452,9 @@ const MentionPopover = forwardRef< }, [items, query, currentWorkingDir]); const getSelectionText = (item: DisplayItem): string => { + if (item.itemType === 'Agent') { + return '@' + item.name + ' '; + } if (item.itemType === 'Skill') { return `Use the ${item.name} skill to `; } @@ -486,17 +492,34 @@ const MentionPopover = forwardRef< throwOnError: true, }); if (cancelled) return; - const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({ - name: cmd.command, - extra: cmd.help, - itemType: cmd.command_type, - relativePath: cmd.command, - })); + const commandItems: DisplayItem[] = (response.data?.commands || []) + .filter((cmd) => cmd.command_type !== 'Agent') + .map((cmd) => ({ + name: cmd.command, + extra: cmd.help, + itemType: cmd.command_type, + relativePath: cmd.command, + })); setItems(commandItems); } else { - const scannedFiles = await scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()); + // Fetch agents from server and scan files in parallel + const [agentResponse, scannedFiles] = await Promise.all([ + getSlashCommands({ + query: { working_dir: currentWorkingDir }, + throwOnError: true, + }).catch(() => null), + scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()), + ]); if (cancelled) return; - setItems(scannedFiles); + const agentItems: DisplayItem[] = (agentResponse?.data?.commands || []) + .filter((cmd) => cmd.command_type === 'Agent') + .map((cmd) => ({ + name: cmd.command, + extra: cmd.help, + itemType: cmd.command_type, + relativePath: cmd.command, + })); + setItems([...agentItems, ...scannedFiles]); } } catch (error) { if (!cancelled) { @@ -572,7 +595,9 @@ const MentionPopover = forwardRef< {isLoading ? (
- {intl.formatMessage(isSlashCommand ? i18n.loadingCommands : i18n.scanningFiles)} + + {intl.formatMessage(isSlashCommand ? i18n.loadingCommands : i18n.scanningFiles)} +
) : ( <> @@ -607,7 +632,9 @@ const MentionPopover = forwardRef< {!isLoading && displayItems.length === 0 && query && (
- {intl.formatMessage(isSlashCommand ? i18n.noCommandsFound : i18n.noItemsFound, { query })} + {intl.formatMessage(isSlashCommand ? i18n.noCommandsFound : i18n.noItemsFound, { + query, + })}
)} diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 526d6ddbc227..80a0f24a0255 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -28,7 +28,7 @@ const i18n = defineMessages({ description: { id: 'appsView.description', defaultMessage: - 'Applications from your MCP servers and Apps build by goose itself. You can ask it to create new apps through the chat interface and they will appear here.', + 'Applications from your MCP servers and Apps built by goose itself. You can ask it to create new apps through the chat interface and they will appear here.', }, loading: { id: 'appsView.loading', @@ -313,7 +313,9 @@ export default function AppsView() { )} {app.mcpServers && app.mcpServers.length > 0 && ( - {isCustomApp ? intl.formatMessage(i18n.customApp) : app.mcpServers.join(', ')} + {isCustomApp + ? intl.formatMessage(i18n.customApp) + : app.mcpServers.join(', ')} )} diff --git a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx index cb7098180a60..59e543679a36 100644 --- a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx +++ b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx @@ -48,7 +48,7 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) { const intl = useIntl(); const navigate = useNavigate(); const { read, upsert, getProviders } = useConfig(); - const { refreshCurrentModelAndProvider } = useModelAndProvider(); + const { getFallbackModelAndProvider, refreshCurrentModelAndProvider } = useModelAndProvider(); const [isCheckingProvider, setIsCheckingProvider] = useState(true); const [hasProvider, setHasProvider] = useState(false); @@ -67,7 +67,25 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) { for (let attempt = 0; attempt <= retries; attempt++) { try { const provider = (await read('GOOSE_PROVIDER', false, { throwOnError: true })) as string | null; - setHasProvider(!!provider?.trim()); + if (provider?.trim()) { + setHasProvider(true); + setIsCheckingProvider(false); + return; + } + + const fallback = await getFallbackModelAndProvider(); + if (fallback.provider?.trim() && fallback.model?.trim()) { + const configuredProvider = (await read('GOOSE_PROVIDER', false)) as string | null; + const configuredModel = (await read('GOOSE_MODEL', false)) as string | null; + if (configuredProvider?.trim() && configuredModel?.trim()) { + await refreshCurrentModelAndProvider(); + setHasProvider(true); + setIsCheckingProvider(false); + return; + } + } + + setHasProvider(false); setIsCheckingProvider(false); return; } catch (error) { diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 0e6294582612..bc3f5e8126d8 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -32,6 +32,14 @@ const i18n = defineMessages({ }, configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' }, openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' }, + taskNotifications: { + id: 'settings.notifications.task.title', + defaultMessage: 'Task completion notifications', + }, + taskNotificationsDesc: { + id: 'settings.notifications.task.description', + defaultMessage: 'Notify when Goose finishes a task while the window is in the background', + }, menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' }, menuBarIconDesc: { id: 'settings.menuBarIcon.description', @@ -209,6 +217,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true); const [wakelockEnabled, setWakelockEnabled] = useState(true); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [isMacOS, setIsMacOS] = useState(false); const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false); @@ -258,6 +267,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti setWakelockEnabled(enabled); }); + window.electron.getSetting('enableNotifications').then((enabled) => { + setNotificationsEnabled(enabled ?? true); + }); + if (isMacOS) { window.electron.getDockIconState().then((enabled) => { setDockIconEnabled(enabled); @@ -316,6 +329,12 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti } }; + const handleNotificationsToggle = async (checked: boolean) => { + setNotificationsEnabled(checked); + await window.electron.setSetting('enableNotifications', checked); + trackSettingToggled('task_notifications', checked); + }; + const handleShowPricingToggle = async (checked: boolean) => { setShowPricing(checked); await window.electron.setSetting('showPricing', checked); @@ -371,6 +390,24 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti +
+
+

+ {intl.formatMessage(i18n.taskNotifications)} +

+

+ {intl.formatMessage(i18n.taskNotificationsDesc)} +

+
+
+ +
+
+

{intl.formatMessage(i18n.menuBarIcon)}

diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 370952f58a37..6ad2559c5055 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -117,7 +117,7 @@ function ProviderCards({ const configureProviderViaModal = useCallback( async (provider: ProviderDetails) => { - if (provider.provider_type === 'Custom' || provider.provider_type === 'Declarative') { + if (provider.provider_type === 'Custom') { const { getCustomProvider } = await import('../../../api'); const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true }); diff --git a/ui/desktop/src/components/skills/SkillsView.tsx b/ui/desktop/src/components/skills/SkillsView.tsx index 39e027f3781e..310d6364c5fa 100644 --- a/ui/desktop/src/components/skills/SkillsView.tsx +++ b/ui/desktop/src/components/skills/SkillsView.tsx @@ -63,6 +63,7 @@ const i18n = defineMessages({ interface SkillEntry { name: string; description: string; + origin?: string; } function SkillItem({ skill }: { skill: SkillEntry }) { @@ -72,6 +73,11 @@ function SkillItem({ skill }: { skill: SkillEntry }) {

{skill.name}

+ {skill.origin ? ( + + MCP · {skill.origin} + + ) : null}

{skill.description}

@@ -127,6 +133,7 @@ export default function SkillsView() { .map((cmd) => ({ name: cmd.command, description: cmd.help, + origin: cmd.origin ?? undefined, })); setSkills(skillEntries); } catch (err) { @@ -216,7 +223,7 @@ export default function SkillsView() { variant="outline" size="sm" className="flex items-center gap-2" - disabled + hidden title={intl.formatMessage(i18n.comingSoon)} > diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 0dafe833acbe..cf74bb31c946 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -78,7 +78,7 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => }; export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => { - const timeout = 10000; + const timeout = 30000; const interval = 100; const maxAttempts = Math.ceil(timeout / interval); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e2eb68b3fc2b..7d84b6253be2 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { defineMessages, useIntl } from '../i18n'; import { v7 as uuidv7 } from 'uuid'; import { AppEvents } from '../constants/events'; import { ChatState } from '../types/chatState'; @@ -227,7 +228,7 @@ function createEventProcessor( dispatch: React.Dispatch, onFinish: (error?: string) => void, sessionId: string, - onReloadNeeded?: () => void, + onReloadNeeded?: () => void ) { let currentMessages = initialMessages; const reduceMotion = prefersReducedMotion(); @@ -343,11 +344,23 @@ function createEventProcessor( return processEvent; } +const i18n = defineMessages({ + notificationTitle: { + id: 'chat.notification.taskComplete.title', + defaultMessage: 'Goose finished the task.', + }, + notificationBody: { + id: 'chat.notification.taskComplete.body', + defaultMessage: 'Click here to bring Goose back into focus.', + }, +}); + export function useChatStream({ sessionId, onStreamFinish, onSessionLoaded, }: UseChatStreamProps): UseChatStreamReturn { + const intl = useIntl(); const [state, dispatch] = useReducer(streamReducer, initialState); // Long-lived SSE connection for this session @@ -358,7 +371,6 @@ export function useChatStream({ const activeRequestSessionIdRef = useRef(null); const activeAbortRef = useRef(null); const activeUnsubscribeRef = useRef<(() => void) | null>(null); - const lastInteractionTimeRef = useRef(Date.now()); // When ActiveRequests fires before resumeAgent populates messages (cold mount), // defer the reattach until the session is loaded so the event processor has // the full conversation history. Events are buffered in the meantime. @@ -399,12 +411,21 @@ export function useChatStream({ dispatch({ type: 'STREAM_FINISH', payload: error }); - const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current; - if (!error && timeSinceLastInteraction > 60000) { - window.electron.showNotification({ - title: 'goose finished the task.', - body: 'Click here to expand.', - }); + if (!error) { + try { + const [notificationsEnabled, anyWindowFocused] = await Promise.all([ + window.electron.getSetting('enableNotifications'), + window.electron.isAnyWindowFocused(), + ]); + if (notificationsEnabled === true && !anyWindowFocused) { + window.electron.showNotification({ + title: intl.formatMessage(i18n.notificationTitle), + body: intl.formatMessage(i18n.notificationBody), + }); + } + } catch (notifyError) { + console.warn('Failed to show task completion notification:', notifyError); + } } const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); @@ -444,7 +465,7 @@ export function useChatStream({ onStreamFinish(); }, - [onStreamFinish, sessionId] + [intl, onStreamFinish, sessionId] ); // Reload the full conversation from the server, e.g. after the SSE @@ -453,14 +474,16 @@ export function useChatStream({ getSession({ path: { session_id: sessionId }, throwOnError: true, - }).then((response) => { - const session = response.data as Session; - if (session?.conversation) { - dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); - } - }).catch((e) => { - console.warn('Failed to reload conversation after buffer overflow:', e); - }); + }) + .then((response) => { + const session = response.data as Session; + if (session?.conversation) { + dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); + } + }) + .catch((e) => { + console.warn('Failed to reload conversation after buffer overflow:', e); + }); }, [sessionId]); // Perform the actual reattach: wire up an event processor and listener @@ -479,7 +502,7 @@ export function useChatStream({ dispatch, onFinish, sessionId, - reloadConversation, + reloadConversation ); // Replay any events that were buffered during cold-mount wait @@ -523,7 +546,7 @@ export function useChatStream({ }); activeUnsubscribeRef.current = unsubscribe; }, - [sessionId, addListener, onFinish, reloadConversation], + [sessionId, addListener, onFinish, reloadConversation] ); doReattachRef.current = doReattach; @@ -582,7 +605,7 @@ export function useChatStream({ targetSessionId: string, userMessage: Message, currentMessages: Message[], - overrideConversation?: Message[], + overrideConversation?: Message[] ) => { const requestId = uuidv7(); const abortController = new AbortController(); @@ -596,7 +619,7 @@ export function useChatStream({ dispatch, onFinish, targetSessionId, - reloadConversation, + reloadConversation ); const unsubscribe = addListener(requestId, (event) => { @@ -801,8 +824,6 @@ export function useChatStream({ return; } - lastInteractionTimeRef.current = Date.now(); - // Emit session-created event for first message in a new session if (!hasExistingMessages && hasNewMessage) { window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); @@ -876,8 +897,6 @@ export function useChatStream({ return; } - lastInteractionTimeRef.current = Date.now(); - const responseMessage = createElicitationResponseMessage(elicitationId, userData); const currentMessages = [...currentState.messages, responseMessage]; @@ -961,7 +980,6 @@ export function useChatStream({ activeRequestSessionIdRef.current = null; dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - lastInteractionTimeRef.current = Date.now(); }, []); const onMessageUpdate = useCallback( diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 8c6fcc592b18..c7f53f9a4b77 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -21,7 +21,7 @@ "defaultMessage": "Custom app" }, "appsView.description": { - "defaultMessage": "Applications from your MCP servers and Apps build by goose itself. You can ask it to create new apps through the chat interface and they will appear here." + "defaultMessage": "Applications from your MCP servers and Apps built by goose itself. You can ask it to create new apps through the chat interface and they will appear here." }, "appsView.errorLoading": { "defaultMessage": "Error loading apps: {error}" @@ -113,6 +113,12 @@ "cardButtons.launch": { "defaultMessage": "Launch" }, + "chat.notification.taskComplete.body": { + "defaultMessage": "Click here to bring Goose back into focus." + }, + "chat.notification.taskComplete.title": { + "defaultMessage": "Goose finished the task." + }, "chatInput.contextWindow": { "defaultMessage": "Context window" }, @@ -1449,7 +1455,7 @@ "defaultMessage": "Downloaded" }, "huggingFaceModelSearch.downloading": { - "defaultMessage": "Downloading\u2026" + "defaultMessage": "Downloading…" }, "huggingFaceModelSearch.loadingVariants": { "defaultMessage": "Loading variants..." @@ -1845,7 +1851,7 @@ "defaultMessage": "Vision" }, "localInferenceSettings.visionEncoderDownloading": { - "defaultMessage": "Vision encoder downloading\u2026" + "defaultMessage": "Vision encoder downloading…" }, "localInferenceSettings.visionEncoderNotDownloaded": { "defaultMessage": "Vision encoder not downloaded" @@ -4052,6 +4058,12 @@ "settings.notifications.openSettings": { "defaultMessage": "Open Settings" }, + "settings.notifications.task.description": { + "defaultMessage": "Notify when Goose finishes a task while the window is in the background" + }, + "settings.notifications.task.title": { + "defaultMessage": "Task completion notifications" + }, "settings.notifications.title": { "defaultMessage": "Notifications" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 821ae797837e..28332b8c2c29 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -749,11 +749,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { // Nudge the user if mesh is their provider but isn't running. // Delay to let the renderer mount before sending the IPC event. setTimeout(() => { - mesh.checkProviderRunning(goosedClient).then((ok) => { - if (!ok && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mesh-not-running'); - } - }).catch(() => {}); + mesh + .checkProviderRunning(goosedClient) + .then((ok) => { + if (!ok && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('mesh-not-running'); + } + }) + .catch(() => {}); }, 5000); // Let windowStateKeeper manage the window @@ -1359,6 +1362,7 @@ const validSettingKeys: Set = new Set([ 'showMenuBarIcon', 'showDockIcon', 'enableWakelock', + 'enableNotifications', 'spellcheckEnabled', 'externalGoosed', 'globalShortcut', @@ -1572,6 +1576,10 @@ ipcMain.handle('get-spellcheck-state', () => { } }); +ipcMain.handle('is-any-window-focused', () => { + return BrowserWindow.getFocusedWindow() !== null; +}); + // Add file/directory selection handler ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { const dialogOptions: OpenDialogOptions = { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 97334ef88b1a..10e1ae0c349d 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -147,6 +147,7 @@ type ElectronAPI = { setSpellcheck: (enable: boolean) => Promise; getSpellcheckState: () => Promise; openNotificationsSettings: () => Promise; + isAnyWindowFocused: () => Promise; onMouseBackButtonClicked: (callback: () => void) => void; offMouseBackButtonClicked: (callback: () => void) => void; on: ( @@ -267,6 +268,7 @@ const electronAPI: ElectronAPI = { setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable), getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'), openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'), + isAnyWindowFocused: () => ipcRenderer.invoke('is-any-window-focused'), onMouseBackButtonClicked: (callback: () => void) => { // Wrapper that ignores the event parameter. const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback(); diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 6e491b9c3561..4afefd5a538e 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -33,6 +33,7 @@ export interface Settings { showMenuBarIcon: boolean; showDockIcon: boolean; enableWakelock: boolean; + enableNotifications: boolean; spellcheckEnabled: boolean; externalGoosed: ExternalGoosedConfig; globalShortcut?: string | null; @@ -69,6 +70,7 @@ export const defaultSettings: Settings = { showMenuBarIcon: true, showDockIcon: true, enableWakelock: false, + enableNotifications: true, spellcheckEnabled: true, keyboardShortcuts: defaultKeyboardShortcuts, externalGoosed: { diff --git a/ui/desktop/tests/integration/test_providers.test.ts b/ui/desktop/tests/integration/test_providers.test.ts new file mode 100644 index 000000000000..44feb2a7c00f --- /dev/null +++ b/ui/desktop/tests/integration/test_providers.test.ts @@ -0,0 +1,86 @@ +/** + * Provider smoke tests — normal mode (direct tool calls). + * + * Each available provider/model pair gets its own test that spawns `goose run` + * with the developer builtin, asks the model to read files via the shell tool, + * and validates the output. + */ + +import { expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildGoose, discoverTestCases, runGoose, providerTest } from './test_providers_lib'; + +const BUILTINS = 'developer'; +const TEST_CONTENT = 'test-content-abc123'; + +let gooseBin: string; +let testFile: string; + +beforeAll(() => { + gooseBin = buildGoose(); + + const targetDir = path.resolve(process.cwd(), '..', '..', 'target'); + fs.mkdirSync(targetDir, { recursive: true }); + testFile = path.join(targetDir, 'test-content.txt'); + fs.writeFileSync(testFile, TEST_CONTENT + '\n'); +}); + +const { testAgentic, testNonAgentic } = providerTest(discoverTestCases()); + +testNonAgentic('reads files via shell tool', async (tc) => { + const testdir = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-test-')); + try { + const tokenA = `smoke-alpha-${Math.floor(Math.random() * 32768)}`; + const tokenB = `smoke-bravo-${Math.floor(Math.random() * 32768)}`; + fs.writeFileSync(path.join(testdir, 'part-a.txt'), tokenA + '\n'); + fs.writeFileSync(path.join(testdir, 'part-b.txt'), tokenB + '\n'); + + const output = await runGoose( + gooseBin, + testdir, + 'Use the shell tool to cat ./part-a.txt and ./part-b.txt, then reply with ONLY the contents of both files, one per line, nothing else.', + BUILTINS, + { GOOSE_PROVIDER: tc.provider, GOOSE_MODEL: tc.model } + ); + + const shellToolPattern = /(shell \| developer)|(▸.*shell)/; + expect( + shellToolPattern.test(output), + `Expected model to use shell tool\n\nFull output:\n${output}` + ).toBe(true); + expect( + output, + `Expected output to contain token from part-a.txt (${tokenA})\n\nFull output:\n${output}` + ).toContain(tokenA); + expect( + output, + `Expected output to contain token from part-b.txt (${tokenB})\n\nFull output:\n${output}` + ).toContain(tokenB); + } finally { + fs.rmSync(testdir, { recursive: true, force: true }); + } +}); + +testAgentic('reads file contents', async (tc) => { + const testdir = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-test-')); + try { + fs.copyFileSync(testFile, path.join(testdir, 'test-content.txt')); + + const output = await runGoose( + gooseBin, + testdir, + 'read ./test-content.txt and output its contents exactly', + BUILTINS, + { GOOSE_PROVIDER: tc.provider, GOOSE_MODEL: tc.model } + ); + + expect( + output.toLowerCase(), + `Expected model output to contain "${TEST_CONTENT}"\n\nFull output:\n${output}` + ).toContain(TEST_CONTENT.toLowerCase()); + } finally { + fs.rmSync(testdir, { recursive: true, force: true }); + } +}); diff --git a/ui/desktop/tests/integration/test_providers_code_exec.test.ts b/ui/desktop/tests/integration/test_providers_code_exec.test.ts new file mode 100644 index 000000000000..d166c126cdc1 --- /dev/null +++ b/ui/desktop/tests/integration/test_providers_code_exec.test.ts @@ -0,0 +1,50 @@ +/** + * Provider smoke tests — code execution mode (JS batching). + * + * Each available (non-agentic) provider/model pair gets its own test that + * spawns `goose run` with the memory + code_execution builtins and validates + * that the code_execution tool was invoked. + */ + +import { expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildGoose, discoverTestCases, runGoose, providerTest } from './test_providers_lib'; + +const BUILTINS = 'memory,code_execution'; + +let gooseBin: string; + +beforeAll(() => { + gooseBin = buildGoose(); +}); + +const { testAll } = providerTest(discoverTestCases({ skipAgentic: true })); + +testAll('invokes code_execution tool', async (tc) => { + const testdir = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-codeexec-')); + try { + const output = await runGoose( + gooseBin, + testdir, + "Store a memory with category 'test' and data 'hello world', then retrieve all memories from category 'test'.", + BUILTINS, + { GOOSE_PROVIDER: tc.provider, GOOSE_MODEL: tc.model } + ); + + // Matches: "execute_typescript | code_execution", "get_function_details | code_execution", + // "tool call | execute", "tool calls | execute" (old format) + // "▸ execute N tool call" (new format with tool_graph) + // "▸ execute_typescript" (plain tool name in output) + const codeExecPattern = + /(execute_typescript \| code_execution)|(get_function_details \| code_execution)|(tool calls? \| execute)|(▸.*execute.*tool call)|(▸ execute_typescript)/; + + expect( + codeExecPattern.test(output), + `Expected code_execution tool to be called\n\nFull output:\n${output}` + ).toBe(true); + } finally { + fs.rmSync(testdir, { recursive: true, force: true }); + } +}); diff --git a/ui/desktop/tests/integration/test_providers_lib.ts b/ui/desktop/tests/integration/test_providers_lib.ts new file mode 100644 index 000000000000..7a90ad104b2a --- /dev/null +++ b/ui/desktop/tests/integration/test_providers_lib.ts @@ -0,0 +1,414 @@ +/** + * Shared library for provider smoke tests. + * + * Ported from scripts/test_providers_lib.sh — keeps the same provider config, + * allowed-failure list, agentic-provider list, and environment detection. + */ + +import { test } from 'vitest'; +import { execSync, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Provider configuration +// --------------------------------------------------------------------------- + +type ModelEntry = string | { name: string; flaky: true }; + +interface ProviderConfig { + provider: string; + models: ModelEntry[]; + agentic?: boolean; + available: () => boolean; +} + +function modelName(entry: ModelEntry): string { + return typeof entry === 'string' ? entry : entry.name; +} + +function modelFlaky(entry: ModelEntry): boolean { + return typeof entry !== 'string' && entry.flaky; +} + +function hasEnv(name: string): boolean { + return !!process.env[name]; +} + +function hasCmd(name: string): boolean { + try { + execSync(`command -v ${name}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function hasFile(p: string): boolean { + return fs.existsSync(p); +} + +function getProviders(): ProviderConfig[] { + return [ + { + provider: 'openrouter', + models: [ + 'google/gemini-2.5-pro', + 'anthropic/claude-sonnet-4.5', + { name: 'qwen/qwen3-coder:exacto', flaky: true }, + 'z-ai/glm-4.6:exacto', + { name: 'nvidia/nemotron-3-nano-30b-a3b:free', flaky: true }, + ], + available: () => hasEnv('OPENROUTER_API_KEY'), + }, + { + provider: 'xai', + models: ['grok-3'], + available: () => hasEnv('XAI_API_KEY'), + }, + { + provider: 'openai', + models: ['gpt-4o', 'gpt-4o-mini', { name: 'gpt-3.5-turbo', flaky: true }, 'gpt-5'], + available: () => hasEnv('OPENAI_API_KEY'), + }, + { + provider: 'anthropic', + models: ['claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'], + available: () => hasEnv('ANTHROPIC_API_KEY'), + }, + { + provider: 'google', + models: [ + 'gemini-2.5-pro', + { name: 'gemini-2.5-flash', flaky: true }, + { name: 'gemini-3-pro-preview', flaky: true }, + 'gemini-3-flash-preview', + ], + available: () => hasEnv('GOOGLE_API_KEY'), + }, + { + provider: 'tetrate', + models: ['claude-sonnet-4-20250514'], + available: () => hasEnv('TETRATE_API_KEY'), + }, + { + provider: 'databricks', + models: ['databricks-claude-sonnet-4', 'gemini-2-5-flash', 'gpt-4o'], + available: () => hasEnv('DATABRICKS_HOST') && hasEnv('DATABRICKS_TOKEN'), + }, + { + provider: 'azure_openai', + models: [process.env.AZURE_OPENAI_DEPLOYMENT_NAME ?? ''], + available: () => hasEnv('AZURE_OPENAI_ENDPOINT') && hasEnv('AZURE_OPENAI_DEPLOYMENT_NAME'), + }, + { + provider: 'aws_bedrock', + models: ['us.anthropic.claude-sonnet-4-5-20250929-v1:0'], + available: () => + hasEnv('AWS_REGION') && (hasEnv('AWS_PROFILE') || hasEnv('AWS_ACCESS_KEY_ID')), + }, + { + provider: 'gcp_vertex_ai', + models: ['gemini-2.5-pro'], + available: () => hasEnv('GCP_PROJECT_ID'), + }, + { + provider: 'snowflake', + models: ['claude-sonnet-4-5'], + available: () => hasEnv('SNOWFLAKE_HOST') && hasEnv('SNOWFLAKE_TOKEN'), + }, + { + provider: 'venice', + models: ['llama-3.3-70b'], + available: () => hasEnv('VENICE_API_KEY'), + }, + { + provider: 'litellm', + models: ['gpt-4o-mini'], + available: () => hasEnv('LITELLM_API_KEY'), + }, + { + provider: 'sagemaker_tgi', + models: ['sagemaker-tgi-endpoint'], + available: () => hasEnv('SAGEMAKER_ENDPOINT_NAME') && hasEnv('AWS_REGION'), + }, + { + provider: 'github_copilot', + models: ['gpt-4.1'], + available: () => + hasEnv('GITHUB_COPILOT_TOKEN') || + hasFile(path.join(os.homedir(), '.config/goose/github_copilot_token.json')), + }, + { + provider: 'chatgpt_codex', + models: ['gpt-5.4'], + available: () => + hasEnv('CHATGPT_CODEX_TOKEN') || + hasFile(path.join(os.homedir(), '.config/goose/chatgpt_codex/tokens.json')), + }, + { + provider: 'claude-code', + models: ['default'], + agentic: true, + available: () => hasCmd('claude'), + }, + { + provider: 'cursor-agent', + models: ['auto'], + agentic: true, + available: () => hasCmd('cursor-agent'), + }, + { + provider: 'ollama', + models: ['qwen3'], + available: () => hasEnv('OLLAMA_HOST') || hasCmd('ollama'), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripQuotes(s: string): string { + if ( + s.length >= 2 && + ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) + ) { + return s.slice(1, -1); + } + return s; +} + +function loadDotenv(): void { + // Resolve .env from the repository root (two levels up from ui/desktop). + const repoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const envPath = path.join(repoRoot, '.env'); + if (!fs.existsSync(envPath)) return; + const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx); + const value = stripQuotes(trimmed.slice(eqIdx + 1)); + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +function shouldSkipProvider(provider: string): boolean { + const skip = process.env.SKIP_PROVIDERS; + if (!skip) return false; + return skip + .split(',') + .map((s) => s.trim()) + .includes(provider); +} + +// --------------------------------------------------------------------------- +// Build goose binary +// --------------------------------------------------------------------------- + +export function buildGoose(): string { + if (!process.env.SKIP_BUILD) { + console.error('Building goose...'); + execSync('cargo build --bin goose', { stdio: 'inherit' }); + console.error(''); + } else { + console.error('Skipping build (SKIP_BUILD is set)...'); + console.error(''); + } + return path.resolve(process.cwd(), '..', '..', 'target/debug/goose'); +} + +// --------------------------------------------------------------------------- +// Test case discovery +// --------------------------------------------------------------------------- + +export interface TestCase { + provider: string; + model: string; + available: boolean; + flaky: boolean; + agentic: boolean; + skippedReason?: string; +} + +export function discoverTestCases(options?: { skipAgentic?: boolean }): TestCase[] { + loadDotenv(); + const skipAgentic = options?.skipAgentic ?? false; + const providers = getProviders(); + + const testCases: TestCase[] = []; + + for (const pc of providers) { + const providerAvailable = pc.available(); + const agentic = pc.agentic ?? false; + + for (const entry of pc.models) { + const model = modelName(entry); + const flaky = modelFlaky(entry); + + if (!providerAvailable) { + testCases.push({ + provider: pc.provider, + model, + available: false, + flaky, + agentic, + skippedReason: 'prerequisites not met', + }); + } else if (shouldSkipProvider(pc.provider)) { + testCases.push({ + provider: pc.provider, + model, + available: false, + flaky, + agentic, + skippedReason: 'SKIP_PROVIDERS', + }); + } else if (skipAgentic && agentic) { + testCases.push({ + provider: pc.provider, + model, + available: false, + flaky, + agentic, + skippedReason: 'agentic provider skipped in this mode', + }); + } else { + testCases.push({ + provider: pc.provider, + model, + available: true, + flaky, + agentic, + }); + } + } + } + + return testCases; +} + +// --------------------------------------------------------------------------- +// Test registration helpers +// --------------------------------------------------------------------------- + +type ProviderTestFn = (tc: TestCase) => Promise; + +function registerTests(label: string, cases: TestCase[], fn: ProviderTestFn): void { + const available = cases.filter((tc) => tc.available && !tc.flaky); + const flaky = cases.filter((tc) => tc.available && tc.flaky); + const skipped = cases.filter((tc) => !tc.available); + + if (available.length > 0) { + test.each(available)(`${label} — $provider / $model`, async (tc) => { + await fn(tc); + }); + } + + if (flaky.length > 0) { + // Use a longer vitest timeout (90s) so the internal runGoose timeout (55s) + // fires first — that rejection is catchable and the test passes as "allowed". + test.each(flaky)( + `${label} — $provider / $model (flaky)`, + async (tc) => { + try { + await fn(tc); + } catch (err) { + console.warn(`Flaky test ${tc.provider}/${tc.model} failed (allowed): ${err}`); + } + }, + 90_000 + ); + } + + if (skipped.length > 0) { + test.skip.each(skipped)(`${label} — $provider / $model — $skippedReason`, () => {}); + } +} + +/** + * Build decorator-style test registrars from a set of discovered test cases. + * + * Usage: + * const { testAll, testAgentic, testNonAgentic } = providerTest(cases); + * + * testAll('reads a file', async (tc) => { ... }); + * testAgentic('delegates work', async (tc) => { ... }); + * testNonAgentic('uses shell tool', async (tc) => { ... }); + */ +export function providerTest(cases: TestCase[]) { + const agentic = cases.filter((tc) => tc.agentic); + const nonAgentic = cases.filter((tc) => !tc.agentic); + + return { + testAll: (label: string, fn: ProviderTestFn) => registerTests(label, cases, fn), + testAgentic: (label: string, fn: ProviderTestFn) => registerTests(label, agentic, fn), + testNonAgentic: (label: string, fn: ProviderTestFn) => registerTests(label, nonAgentic, fn), + }; +} + +// --------------------------------------------------------------------------- +// Utility: run goose binary and capture output +// --------------------------------------------------------------------------- + +export function runGoose( + gooseBin: string, + cwd: string, + prompt: string, + builtins: string, + env: Record, + timeoutMs: number = 55_000 +): Promise { + return new Promise((resolve, reject) => { + const child: ChildProcess = spawn( + gooseBin, + ['run', '--text', prompt, '--with-builtin', builtins], + { + cwd, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + let output = ''; + let settled = false; + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + child.kill('SIGKILL'); + reject(new Error(`goose timed out after ${timeoutMs}ms\n\nPartial output:\n${output}`)); + } + }, timeoutMs); + + child.stdout?.on('data', (d) => { + output += String(d); + }); + child.stderr?.on('data', (d) => { + output += String(d); + }); + + child.on('close', () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(output); + } + }); + + child.on('error', (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(`spawn error: ${err.message}`); + } + }); + }); +} diff --git a/ui/desktop/vitest.config.ts b/ui/desktop/vitest.config.ts index 7a2965c12f80..f745b9244dcc 100644 --- a/ui/desktop/vitest.config.ts +++ b/ui/desktop/vitest.config.ts @@ -1,7 +1,7 @@ /// -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' -import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; const cfg = { plugins: [react()], @@ -17,6 +17,6 @@ const cfg = { css: true, include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'], }, -} satisfies Record +} satisfies Record; -export default defineConfig(cfg as any) +export default defineConfig(cfg as any); diff --git a/ui/goose-binary/goose-binary-darwin-arm64/package.json b/ui/goose-binary/goose-binary-darwin-arm64/package.json index c5b92eceb94b..05081d92487e 100644 --- a/ui/goose-binary/goose-binary-darwin-arm64/package.json +++ b/ui/goose-binary/goose-binary-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@aaif/goose-binary-darwin-arm64", - "version": "0.17.0", + "version": "0.19.0", "description": "Goose binary for macOS ARM64", "license": "Apache-2.0", "repository": { diff --git a/ui/goose-binary/goose-binary-darwin-x64/package.json b/ui/goose-binary/goose-binary-darwin-x64/package.json index 8cae773e733a..0c60da8d8988 100644 --- a/ui/goose-binary/goose-binary-darwin-x64/package.json +++ b/ui/goose-binary/goose-binary-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@aaif/goose-binary-darwin-x64", - "version": "0.17.0", + "version": "0.19.0", "description": "Goose binary for macOS x64", "license": "Apache-2.0", "repository": { diff --git a/ui/goose-binary/goose-binary-linux-arm64/package.json b/ui/goose-binary/goose-binary-linux-arm64/package.json index e5cb9f289369..1fb17b9e2a9e 100644 --- a/ui/goose-binary/goose-binary-linux-arm64/package.json +++ b/ui/goose-binary/goose-binary-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@aaif/goose-binary-linux-arm64", - "version": "0.17.0", + "version": "0.19.0", "description": "Goose binary for Linux ARM64", "license": "Apache-2.0", "repository": { diff --git a/ui/goose-binary/goose-binary-linux-x64/package.json b/ui/goose-binary/goose-binary-linux-x64/package.json index 7aebcbd27bb2..4159eaee7e8f 100644 --- a/ui/goose-binary/goose-binary-linux-x64/package.json +++ b/ui/goose-binary/goose-binary-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@aaif/goose-binary-linux-x64", - "version": "0.17.0", + "version": "0.19.0", "description": "Goose binary for Linux x64", "license": "Apache-2.0", "repository": { diff --git a/ui/goose-binary/goose-binary-win32-x64/package.json b/ui/goose-binary/goose-binary-win32-x64/package.json index 99d94d7a6ef1..133754e1b577 100644 --- a/ui/goose-binary/goose-binary-win32-x64/package.json +++ b/ui/goose-binary/goose-binary-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@aaif/goose-binary-win32-x64", - "version": "0.17.0", + "version": "0.19.0", "description": "Goose binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md index 3e3d25871a13..da4382ada4c6 100644 --- a/ui/goose2/AGENTS.md +++ b/ui/goose2/AGENTS.md @@ -131,16 +131,61 @@ ThemeProvider manages three axes: - `tauri-plugin-window-state` persists window size and position. - Traffic light offset: `pl-20` (80px) to accommodate macOS window controls. -## Backend Architecture +## Architecture -All AI communication goes through **ACP (Agent Client Protocol)**: -- The Tauri app starts a long-lived `goose serve` process and exposes its WebSocket URL. -- The frontend connects directly to `goose serve` over WebSocket and handles ACP notifications in TypeScript. +**All frontend ↔ backend communication in goose2 flows through a single path:** -For non AI communication, such as configuration: -- Use **Tauri commands** (`invoke()` from `@tauri-apps/api/core`) for request/response operations (sessions, personas, skills, projects, etc.). -- Use **Tauri events** (`listen()` from `@tauri-apps/api/event`) for streaming data from ACP. -- Do **not** add HTTP fetch calls to a backend server, `apiFetch` utilities, or sidecar process management. +``` +React UI ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP) ──► goose (core) +``` + +- The Tauri shell spawns a long-lived `goose serve` process and exposes its WebSocket URL via the `get_goose_serve_url` Tauri command. That is essentially the only Tauri command the frontend needs for backend work — it is how the renderer discovers the ACP endpoint. +- The frontend opens a WebSocket to `goose serve` and talks to it using `@aaif/goose-sdk` (published from `ui/sdk/`). The SDK is generated from the ACP custom-method definitions in `crates/goose-sdk/src/custom_requests.rs`, so every backend method has a typed TypeScript client method. +- `goose-acp` (`crates/goose-acp/src/server.rs`) is the server side of the WebSocket. It implements handlers for the custom ACP methods and calls into the `goose` core crate to do the actual work (providers, config, sessions, dictation, etc.). +- `goose` is the pure domain crate. It knows nothing about Tauri or WebSockets — it just exposes Rust APIs that `goose-acp` handlers invoke. + +**This is the pattern you must follow when adding any new backend-touching feature.** When you are vibecoding in this app, it is very tempting to reach for `invoke()` or add an HTTP fetch — don't. The rule is: if a feature needs to talk to `goose` core, it goes through the SDK → ACP → goose chain above. + +### The canonical example: skills-as-sources (PR #8675) + +The skills → sources migration in [#8675](https://github.com/block/goose/pull/8675) is the clearest illustration of the rule. **It deleted 319 lines of Tauri-command code in `src-tauri/src/commands/skills.rs` and replaced them with ACP custom methods.** If you find yourself wanting to add an `invoke()` command that proxies to `goose`, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints: + +1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose//", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.). Keep the docs on those structs aligned with the implementation: today `_goose/sources/list` is still skill-only; create/import take an explicit target scope (`global`, plus `projectDir` for project sources), while update/delete/export operate on an existing skill by absolute `path`. +2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `sacp::Error::invalid_params()` / `internal_error()`. +3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point. +4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files. +5. **Call it from the frontend via a feature `api/` module.** See `ui/goose2/src/features/skills/api/skills.ts`. It calls `getClient()` from `acpConnection.ts` and invokes the SDK, then adapts the generic `SourceEntry` shape into a feature-friendly `SkillInfo`: + ```ts + export async function listSkills(): Promise { + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toSkillInfo); + } + ``` + Feature code (hooks, stores, UI) imports from that `api/` module — it never touches the ACP client directly. + +**Note on typed vs untyped calls.** Skills currently uses `client.extMethod("_goose/sources/...", ...)` (the untyped escape hatch) because it reshapes a generic `Source` API into skill-specific types. The **preferred** shape for new features is the typed generated methods — `client.goose.GooseFooBar({ ... })` — as used by dictation (`client.goose.GooseDictationTranscribe`) and the provider inventory (`client.goose.GooseProvidersList`). Reach for `extMethod()` only when you have a real reason to bypass the generated types. + +For a minimal frontend `api/` wrapper using the typed shape, see `ui/goose2/src/features/providers/api/inventory.ts` — ~30 lines, typed SDK calls, thin adapter. For a fully worked end-to-end feature including OS-keychain handling and progress streaming, see the voice dictation feature ([#8609](https://github.com/block/goose/pull/8609)) and `ui/goose2/src/shared/api/dictation.ts`. + +### When `invoke()` is still appropriate + +Tauri commands (`invoke()` from `@tauri-apps/api/core`) are reserved for things that genuinely belong to the desktop shell, not to `goose` core. In practice that means: + +- `get_goose_serve_url` — bootstrapping the ACP connection. +- Secret storage owned by the OS keychain (e.g. `save_provider_field`, `delete_provider_config` — note dictation still uses these for writing API keys into the OS keychain, because that's a shell concern). +- Window state, filesystem dialogs, and other Tauri-plugin-backed capabilities. + +If the thing you're building is "get data from goose" or "tell goose to do something," it is **not** one of these cases. Add a custom ACP method instead. + +### Don't + +- Don't add HTTP `fetch` calls to a `goose` HTTP API, or reintroduce an `apiFetch` utility. There is no HTTP API for goose2 — the backend is the ACP WebSocket. +- Don't manage a sidecar `goose` process from the renderer. The Tauri shell owns that lifecycle. +- Don't add a new `invoke()` command in `src-tauri/` as a proxy to `goose` core. Add an ACP custom method instead. +- Don't hand-edit `ui/sdk/src/generated/`. Regenerate. +- Don't call the ACP client (`getClient()`) directly from UI components or stores. Go through a `shared/api/*.ts` (or `features//api/*.ts`) module so the SDK surface is mockable in tests. ## Tooling diff --git a/ui/goose2/acp-plus-migration-plan/00-overview.md b/ui/goose2/acp-plus-migration-plan/00-overview.md deleted file mode 100644 index c75b28ab16de..000000000000 --- a/ui/goose2/acp-plus-migration-plan/00-overview.md +++ /dev/null @@ -1,103 +0,0 @@ -# ACP-Plus Migration Plan: Overview - -## Goal - -Move all ACP protocol handling from the Rust Tauri backend into the TypeScript/WebView layer, so the frontend communicates directly with `goose serve` over WebSocket. The Rust layer shrinks to a thin native shell responsible only for: - -1. Spawning and managing the `goose serve` child process -2. Providing the server URL to the frontend -3. Window management / OS integration - -Long-term, config, personas, skills, projects, git, doctor, and all other native operations will also move behind `goose serve` ACP extension methods — eliminating the Rust middleware entirely. - -## Current Architecture - -``` -Frontend (TS) - → invoke("acp_send_message") [Tauri IPC] - → GooseAcpManager [Rust singleton, dedicated thread] - → ClientSideConnection [Rust ACP client over WebSocket] - → goose serve ws://127.0.0.1:/acp [child process] - ← SessionNotification [ACP callback in Rust] - ← TauriMessageWriter [emits Tauri events] - ← listen("acp:text", ...) [Tauri event bus] - → Zustand store updates -``` - -## Target Architecture (Phase A) - -``` -Frontend (TS) - → GooseClient (WebSocket) - → goose serve ws://127.0.0.1:/acp [child process] - ← Client callbacks → direct Zustand store updates - -Tauri Rust shell: - - Spawn goose serve, expose URL - - Config/personas/skills/projects/git/doctor (temporary — Phase B removes these) - - Window management -``` - -## Target Architecture (Phase B — Long-Term) - -``` -Frontend (TS) - → GooseClient (WebSocket) - → goose serve ws://127.0.0.1:/acp - ← Client callbacks → direct Zustand store updates - -Tauri Rust shell (~200 lines): - - Spawn goose serve, expose URL - - Window management -``` - -## Steps - -| Step | File | Summary | -|------|------|---------| -| 01 | `01-expose-goose-serve-url.md` | Add Tauri command to expose the `goose serve` WebSocket URL to the frontend | -| 02 | `02-add-acp-npm-dependencies.md` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to goose2 | -| 03 | `03-create-ts-acp-connection.md` | Create the singleton TypeScript ACP connection manager (WebSocket transport), reconnection logic, and feature flag | -| 04 | `04-create-ts-notification-handler.md` | Port the Rust `SessionEventDispatcher` to TypeScript | -| 05 | `05-create-ts-session-manager.md` | Port session state management and ACP operations to TypeScript | -| 06 | `06-port-session-search.md` | Port session content search from Rust to TypeScript | -| 07 | `07-rewire-shared-api-acp.md` | Replace `invoke()` wrappers in `src/shared/api/acp.ts` with direct TS ACP calls | -| 08 | `08-rewire-hooks.md` | Remove `useAcpStream`, update `useChat`, `useAppStartup`, `AppShell` | -| 09 | `09-delete-rust-acp-code.md` | Delete the Rust ACP middleware and unused dependencies | -| 10 | `10-phase-b-future-native-migration.md` | Plan for moving config/personas/skills/projects/git/doctor to `goose serve` | - -## Ordering & Dependencies - -``` -01 ──┐ - ├──→ 03 ──→ 04 ──→ 05 ──→ 07 ──→ 08 ──→ 09 -02 ──┘ │ - └──→ 06 ──→ 07 -``` - -- Steps 01 and 02 are independent and can be done in parallel. -- Steps 03–06 build on each other, though 06 can proceed in parallel with 04/05. -- Step 07 wires everything together. -- Step 08 removes the old Tauri event listeners. -- Step 09 is cleanup — only after everything works. -- Step 10 is the Phase B roadmap. - -## Key Decisions - -1. **WebSocket transport.** `goose serve` exposes a WebSocket endpoint at `/acp`. Each WS text frame is a single JSON-RPC message. This is the same transport the Rust layer already uses — we are moving the WebSocket client from Rust to TypeScript. WebSocket provides true bidirectional streaming with lower overhead than HTTP+SSE. - -2. **Direct store updates over event bus.** The notification handler calls Zustand store methods directly instead of emitting Tauri events. This eliminates a layer of indirection and the `useAcpStream` hook. - -3. **Reuse `@aaif/goose-acp`.** Already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). Provides `GooseClient`, generated types, and Zod validators. A `createWebSocketStream` helper will be added (either in `@aaif/goose-acp` or locally in goose2) since the package currently only ships `createHttpStream`. - -4. **Auto-approve permissions.** Same as the current Rust implementation — accept the first option on all `request_permission` callbacks. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Tauri CSP blocks localhost WebSocket | CSP is already `null` (disabled) in `tauri.conf.json` | -| `goose serve` not ready when frontend initializes | Rust still does a readiness check; the URL command only resolves after the server is confirmed ready | -| WebSocket disconnection / reconnection | Implement reconnection logic in the connection manager; `GooseClient.closed` signals when the connection drops | -| Replay timing (notifications arriving after `loadSession` resolves) | Port the drain/stabilization logic from Rust, or rely on the `replay_complete` signal from the backend | -| Session state consistency during migration | Feature flag (`useDirectAcp` in `acpFeatureFlag.ts`) routes between old Tauri IPC and new WebSocket path. Default off, flip per-user to test, flip default to on after validation, remove in Step 09 | diff --git a/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md b/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md deleted file mode 100644 index 06ad245bf993..000000000000 --- a/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md +++ /dev/null @@ -1,87 +0,0 @@ -# Step 01: Expose the `goose serve` URL to the Frontend - -## Objective - -Add a Tauri command that returns the WebSocket URL of the running `goose serve` process so the frontend can connect directly via WebSocket. - -## Why - -The Rust layer currently connects to `goose serve` over WebSocket internally and proxies everything. The frontend never knows the server URL. Exposing it lets the TypeScript ACP client connect directly. - -## Changes - -### 1. Re-export `GooseServeProcess` - -**File:** `src-tauri/src/services/acp/mod.rs` - -Add a re-export so the command layer can reference the struct: - -```rust -pub(crate) use goose_serve::GooseServeProcess; -``` - -No changes to `GooseServeProcess` itself — the existing `ws_url()` method already returns `ws://127.0.0.1:/acp`. - -### 2. Add the Tauri command - -**File:** `src-tauri/src/commands/acp.rs` - -Add this command alongside the existing ones: - -```rust -use crate::services::acp::goose_serve::GooseServeProcess; - -/// Return the WebSocket URL of the running goose serve process. -/// -/// This command blocks until the server is confirmed ready. The frontend -/// uses this URL to establish a direct WebSocket ACP connection. -#[tauri::command] -pub async fn get_goose_serve_url() -> Result { - GooseServeProcess::start().await?; - let process = GooseServeProcess::get()?; - Ok(process.ws_url()) -} -``` - -### 3. Register the command - -**File:** `src-tauri/src/lib.rs` - -Add the new command to the `invoke_handler` macro near the other `commands::acp::*` entries: - -```rust -commands::acp::get_goose_serve_url, -``` - -### 4. CSP — no changes needed - -**File:** `src-tauri/tauri.conf.json` - -CSP is currently disabled (`"csp": null`), so the frontend can open WebSocket connections to `ws://127.0.0.1:*` without restriction. - -If CSP is ever re-enabled, add: -``` -connect-src 'self' ws://127.0.0.1:* -``` - -## Verification - -1. `cargo check` in `src-tauri/` — confirms compilation. -2. `cargo clippy --all-targets -- -D warnings` in `src-tauri/`. -3. `cargo fmt` in `src-tauri/`. -4. Add a temporary `console.log(await invoke("get_goose_serve_url"))` in the frontend startup — it should print something like `ws://127.0.0.1:54321/acp`. - -## Files Modified - -| File | Change | -|------|--------| -| `src-tauri/src/services/acp/mod.rs` | Add `pub(crate) use goose_serve::GooseServeProcess` | -| `src-tauri/src/commands/acp.rs` | Add `get_goose_serve_url` command | -| `src-tauri/src/lib.rs` | Register `get_goose_serve_url` in invoke_handler | - -## Notes - -- The existing ACP commands remain functional during migration. They are removed in Step 09. -- `GooseServeProcess::start()` is idempotent — the first call spawns the process; subsequent calls return immediately. -- The readiness check (`wait_for_server_ready`) ensures the URL is only returned after the server is accepting connections. -- The URL includes the `/acp` path — the same WebSocket endpoint the Rust layer currently uses in `thread.rs`. diff --git a/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md b/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md deleted file mode 100644 index 9158bc99a5da..000000000000 --- a/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md +++ /dev/null @@ -1,115 +0,0 @@ -# Step 02: Add ACP NPM Dependencies to goose2 - -## Objective - -Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` as dependencies of the goose2 frontend so we can use the TypeScript ACP client. - -## Why - -The `@aaif/goose-acp` package (located at `ui/acp/` in the monorepo) already provides: - -- **`GooseClient`** — a full TypeScript ACP client wrapping `ClientSideConnection` -- **`GooseExtClient`** — generated typed client for Goose extension methods (`goose/providers/list`, `goose/session/export`, etc.) -- **`createHttpStream`** — an HTTP+SSE transport (we won't use this — we'll use WebSocket instead, see Step 03) -- **Generated types + Zod validators** for all Goose ACP extension method request/response shapes - -This package is already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). goose2 currently does NOT depend on it. - -## Changes - -### 1. Add dependencies - -**File:** `ui/goose2/package.json` - -goose2 has its own `pnpm-lock.yaml` and is not part of the `ui/pnpm-workspace.yaml` workspace. Use the published npm packages: - -```bash -cd ui/goose2 -pnpm add @aaif/goose-acp @agentclientprotocol/sdk@^0.14.1 -``` - -The `@aaif/goose-acp` package declares `@agentclientprotocol/sdk` as a peer dependency (`"*"`). Pin to `^0.14.1` to match the version used by `ui/acp/package.json`. - -### 2. Verify the dependency resolves - -After installation, verify the imports work: - -```bash -cd ui/goose2 -pnpm typecheck -``` - -Create a temporary test file to confirm imports resolve: - -```typescript -// src/shared/api/_test_acp_import.ts (DELETE AFTER VERIFICATION) -import { GooseClient } from "@aaif/goose-acp"; -import type { Client, SessionNotification } from "@agentclientprotocol/sdk"; - -console.log("GooseClient:", GooseClient); -``` - -Run `pnpm typecheck` to confirm no type errors. Then delete the test file. - -### 3. Verify key exports are available - -The following imports must resolve — these are what Steps 03–06 will use: - -From `@aaif/goose-acp`: -```typescript -import { GooseClient } from "@aaif/goose-acp"; -``` - -From `@agentclientprotocol/sdk`: -```typescript -import type { - Client, - SessionNotification, - SessionUpdate, - ContentBlock, - ToolCallContent, - RequestPermissionRequest, - RequestPermissionResponse, - NewSessionRequest, - NewSessionResponse, - LoadSessionRequest, - LoadSessionResponse, - PromptRequest, - PromptResponse, - CancelNotification, - SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, - ForkSessionRequest, - ForkSessionResponse, - ListSessionsRequest, - ListSessionsResponse, - InitializeRequest, - ProtocolVersion, - Implementation, - SessionModelState, - SessionInfoUpdate, - SessionConfigOption, - SessionConfigKind, - SessionConfigSelectOptions, - SessionConfigOptionCategory, -} from "@agentclientprotocol/sdk"; -``` - -## Verification - -1. `pnpm typecheck` passes with no errors related to the new dependencies. -2. `pnpm check` (Biome lint + file sizes) passes. -3. `pnpm test` still passes (no existing tests should break). - -## Files Modified - -| File | Change | -|------|--------| -| `package.json` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to dependencies | -| `pnpm-lock.yaml` | Auto-updated by pnpm | - -## Notes - -- `GooseClient` wraps `ClientSideConnection` from `@agentclientprotocol/sdk` and adds Goose-specific extension methods via `GooseExtClient`. -- The package ships `createHttpStream` (HTTP+SSE transport), but we will use **WebSocket** transport instead. `GooseClient` accepts any `Stream` (a `{ readable, writable }` pair of `ReadableStream` and `WritableStream`). In Step 03 we'll create a `createWebSocketStream` helper. -- The `goose serve` WebSocket endpoint at `/acp` uses simple framing: each WS text frame is a single JSON-RPC message (no newline delimiters needed). This is the same transport the Rust Tauri backend already uses in `thread.rs`. diff --git a/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md b/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md deleted file mode 100644 index 179b09a56cc2..000000000000 --- a/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md +++ /dev/null @@ -1,397 +0,0 @@ -# Step 03: Create the TypeScript ACP Connection Manager - -## Objective - -Create a singleton module that manages the lifecycle of the `GooseClient` connection to `goose serve` over WebSocket. This is the TypeScript equivalent of the Rust `GooseAcpManager::start()` singleton. - -## Why - -All ACP operations (send prompt, list sessions, export, etc.) need a shared, initialized `GooseClient` instance. This module: - -1. Fetches the `goose serve` WebSocket URL from the Rust backend (Step 01's command) -2. Creates a WebSocket `Stream` for the ACP SDK -3. Creates a `GooseClient` with that stream -4. Calls `client.initialize()` to complete the ACP handshake -5. Provides the initialized client to all other modules - -## New Files - -### 1. `src/shared/api/createWebSocketStream.ts` — WebSocket transport for ACP - -The `@agentclientprotocol/sdk` defines a `Stream` as `{ readable: ReadableStream, writable: WritableStream }`. The SDK ships `ndJsonStream` for stdio. The `@aaif/goose-acp` package ships `createHttpStream` for HTTP+SSE. Neither provides a WebSocket transport. - -We need a `createWebSocketStream` that bridges a browser `WebSocket` to the ACP `Stream` interface. The `goose serve` WebSocket protocol sends each WS text frame as a single JSON-RPC message (no newline delimiters). - -```typescript -/** - * WebSocket transport for ACP connections. - * - * Creates a Stream (readable + writable pair of AnyMessage) backed by a - * browser WebSocket connection. Each WS text frame is a single JSON-RPC - * message — no newline delimiters needed. - * - * This matches the framing used by goose serve's /acp WebSocket endpoint - * (see crates/goose-acp/src/transport/websocket.rs). - */ -import type { AnyMessage, Stream } from "@agentclientprotocol/sdk"; - -export function createWebSocketStream(wsUrl: string): Stream { - const ws = new WebSocket(wsUrl); - - // Queue of messages received from the server, consumed by the readable stream. - const incoming: AnyMessage[] = []; - const waiters: Array<() => void> = []; - let closed = false; - - function pushMessage(msg: AnyMessage): void { - incoming.push(msg); - const waiter = waiters.shift(); - if (waiter) waiter(); - } - - function waitForMessage(): Promise { - if (incoming.length > 0 || closed) return Promise.resolve(); - return new Promise((resolve) => waiters.push(resolve)); - } - - // Wait for the WebSocket to open before allowing writes. - const openPromise = new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }); - ws.addEventListener("error", (event) => { - reject(new Error(`WebSocket connection failed: ${event}`)); - }, { once: true }); - }); - - ws.addEventListener("message", (event) => { - if (typeof event.data !== "string") return; - try { - const msg = JSON.parse(event.data) as AnyMessage; - pushMessage(msg); - } catch { - // Ignore malformed JSON - } - }); - - ws.addEventListener("close", () => { - closed = true; - for (const waiter of waiters) waiter(); - waiters.length = 0; - }); - - ws.addEventListener("error", () => { - closed = true; - for (const waiter of waiters) waiter(); - waiters.length = 0; - }); - - const readable = new ReadableStream({ - async pull(controller) { - await waitForMessage(); - while (incoming.length > 0) { - controller.enqueue(incoming.shift()!); - } - if (closed && incoming.length === 0) { - controller.close(); - } - }, - }); - - const writable = new WritableStream({ - async write(msg) { - await openPromise; - ws.send(JSON.stringify(msg)); - }, - close() { - ws.close(); - }, - abort() { - ws.close(); - }, - }); - - return { readable, writable }; -} -``` - -### 2. `src/shared/api/acpConnection.ts` — Singleton connection manager - -The module uses a promise-based singleton pattern: `clientPromise` ensures only one initialization runs at a time, `resolvedClient` caches the result for synchronous access, and if initialization fails, `clientPromise` resets so the next call retries. This mirrors the Rust `OnceCell>` pattern in `manager.rs`. - -The notification handler is registered separately (via `setNotificationHandler()` in Step 04) rather than passed at construction time. This avoids a circular dependency: `acpConnection.ts` creates the client, but `acpNotificationHandler.ts` both needs the client and must be registered with the connection. - -```typescript -/** - * Singleton ACP connection manager. - * - * Manages the lifecycle of the GooseClient connection to goose serve - * over WebSocket. All ACP operations go through the client returned - * by getClient(). - */ -import { invoke } from "@tauri-apps/api/core"; -import { GooseClient } from "@aaif/goose-acp"; -import type { - Client, - SessionNotification, - RequestPermissionRequest, - RequestPermissionResponse, -} from "@agentclientprotocol/sdk"; -import { createWebSocketStream } from "./createWebSocketStream"; - -// Will be set by Step 04 — the notification handler -let notificationHandler: AcpNotificationHandler | null = null; - -/** - * Interface for the notification handler that processes ACP session events. - * Implemented in Step 04 (acpNotificationHandler.ts). - */ -export interface AcpNotificationHandler { - handleSessionNotification(notification: SessionNotification): Promise; -} - -/** - * Register the notification handler. Called once during app initialization - * after the handler is created in Step 04. - */ -export function setNotificationHandler(handler: AcpNotificationHandler): void { - notificationHandler = handler; -} - -// Singleton state -let clientPromise: Promise | null = null; -let resolvedClient: GooseClient | null = null; - -/** - * Build the Client implementation that the ACP SDK calls back into. - * - * This handles two callback types: - * - requestPermission: auto-approve with the first option (same as Rust impl) - * - sessionUpdate: delegate to the registered notification handler - */ -function createClientCallbacks(): () => Client { - return () => ({ - requestPermission: async ( - args: RequestPermissionRequest, - ): Promise => { - const optionId = args.options?.[0]?.optionId ?? "approve"; - return { - outcome: { - type: "selected", - optionId, - }, - }; - }, - - sessionUpdate: async ( - notification: SessionNotification, - ): Promise => { - if (notificationHandler) { - await notificationHandler.handleSessionNotification(notification); - } - }, - }); -} - -/** - * Initialize the ACP connection. - * - * 1. Calls the Rust backend to get the goose serve WebSocket URL - * 2. Creates a GooseClient with WebSocket transport - * 3. Sends the ACP initialize handshake - * - * This is idempotent — calling it multiple times returns the same client. - */ -async function initializeConnection(): Promise { - // Returns something like "ws://127.0.0.1:54321/acp" - const wsUrl: string = await invoke("get_goose_serve_url"); - - const stream = createWebSocketStream(wsUrl); - - const client = new GooseClient(createClientCallbacks(), stream); - - await client.initialize({ - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { - name: "goose2", - version: "0.1.0", - }, - }); - - return client; -} - -/** - * Get the initialized GooseClient singleton. - * - * The first call triggers initialization (fetching the URL, creating the - * WebSocket connection, running the ACP handshake). Subsequent calls return - * the same client immediately. - * - * Throws if initialization fails (e.g., goose serve is not running). - */ -export async function getClient(): Promise { - if (resolvedClient) { - return resolvedClient; - } - - if (!clientPromise) { - clientPromise = initializeConnection() - .then((client) => { - resolvedClient = client; - return client; - }) - .catch((error) => { - clientPromise = null; - throw error; - }); - } - - return clientPromise; -} - -/** - * Check if the client has been initialized. - * Useful for guards that need to know if ACP is ready without triggering init. - */ -export function isClientReady(): boolean { - return resolvedClient !== null; -} - -/** - * Get the client synchronously, or null if not yet initialized. - * Use getClient() for the async version that triggers initialization. - */ -export function getClientSync(): GooseClient | null { - return resolvedClient; -} -``` - -### 3. Reconnection Logic in `acpConnection.ts` - -The WebSocket can drop (laptop sleep, network blip, goose serve restart). The connection manager must detect this and recover. - -**Strategy: reset singleton on close, reconnect on next `getClient()` call.** - -Add to `acpConnection.ts`: - -```typescript -/** - * Monitor the WebSocket connection and reset the singleton when it closes. - * Called once after successful initialization. - */ -function monitorConnection(client: GooseClient): void { - // GooseClient exposes a `closed` promise that resolves when the - // underlying connection terminates. - client.closed - .then(() => { - console.warn("[acp] Connection closed. Will reconnect on next getClient()."); - resolvedClient = null; - clientPromise = null; - }) - .catch(() => { - console.warn("[acp] Connection error. Will reconnect on next getClient()."); - resolvedClient = null; - clientPromise = null; - }); -} -``` - -Call `monitorConnection(client)` at the end of `initializeConnection()`, after the handshake succeeds. - -**In-flight cleanup:** When the connection drops mid-stream, any running prompt will reject (the `client.prompt()` promise rejects when the connection closes). The session manager (Step 05) catches this in `sendPrompt()` and calls `clearWriter()` + sets chat state to idle. The notification handler does NOT need special reconnect awareness — it simply stops receiving events because the connection is gone. - -**What this does NOT do:** -- Auto-reconnect in the background (no polling/retry loop) -- Resume an in-flight prompt after reconnect -- Retry failed operations automatically - -It simply ensures the next `getClient()` call creates a fresh connection. The caller (UI layer) decides whether to retry the operation. - -### 4. Feature Flag: `useDirectAcp` - -To enable safe rollback, add a feature flag that controls whether the frontend uses the new direct WebSocket path or the old Tauri IPC path. - -**File:** `src/shared/api/acpFeatureFlag.ts` - -```typescript -/** - * Feature flag for direct ACP WebSocket connection. - * - * When true: frontend talks to goose serve directly via WebSocket - * When false: frontend uses the old Tauri invoke() → Rust → WebSocket path - * - * This flag is used in Step 07 (rewire-shared-api-acp) to route calls. - * Remove this file after the migration is validated and Step 09 (cleanup) is done. - */ - -const STORAGE_KEY = "goose2_use_direct_acp"; - -export function useDirectAcp(): boolean { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored !== null) return stored === "true"; - } catch { - // localStorage not available - } - // Default: off during migration, flip to true when ready - return false; -} - -export function setUseDirectAcp(enabled: boolean): void { - try { - localStorage.setItem(STORAGE_KEY, String(enabled)); - } catch { - // localStorage not available - } -} -``` - -This lets us: -- Ship Steps 01–06 without affecting any users -- Flip the flag per-user or per-session to test the new path -- Instantly roll back if something breaks (flip flag, refresh) -- Remove the flag in Step 09 when the old Rust code is deleted - -Step 07 will use this flag to route each function: - -```typescript -// Example pattern in src/shared/api/acp.ts (Step 07) -export async function acpSendMessage(...) { - if (useDirectAcp()) { - return sendPrompt(...); // new: TS → WebSocket → goose serve - } - return invoke("acp_send_message", ...); // old: TS → Rust → WebSocket → goose serve -} -``` - -## Verification - -1. `pnpm typecheck` passes. -2. `pnpm check` passes (Biome lint). -3. The modules can be imported without side effects — initialization only happens when `getClient()` is called. -4. Unit test for `createWebSocketStream`: mock `WebSocket`, verify messages flow bidirectionally. -5. Reconnection test: close the WebSocket, verify `resolvedClient` resets to null, verify next `getClient()` creates a fresh connection. -6. Feature flag test: verify `useDirectAcp()` reads from localStorage, defaults to false. - -## Files Created - -| File | Purpose | -|------|---------| -| `src/shared/api/createWebSocketStream.ts` | WebSocket → ACP Stream adapter | -| `src/shared/api/acpConnection.ts` | Singleton ACP connection manager with reconnection | -| `src/shared/api/acpFeatureFlag.ts` | Feature flag for old/new path routing | - -## Dependencies - -- Step 01 (the `get_goose_serve_url` Tauri command must exist) -- Step 02 (`@aaif/goose-acp` and `@agentclientprotocol/sdk` must be installed) - -## Notes - -- The `goose serve` WebSocket endpoint at `/acp` sends one JSON-RPC message per WS text frame (no trailing newline). This is the same framing the Rust Tauri backend uses in `thread.rs`. `createWebSocketStream` performs the same bridging directly in the browser. -- WebSocket is used over HTTP+SSE because it is the same transport the Rust layer already uses with `goose serve`, provides true bidirectional communication on a single persistent connection, and avoids the quirks of `createHttpStream` (fire-and-forget POSTs, session header management). -- The `Client` interface from `@agentclientprotocol/sdk` uses `sessionUpdate` as the callback method name. The Rust `Client` trait calls it `session_notification` — same callback, different naming convention. -- The `protocolVersion` `"2025-03-26"` matches `ProtocolVersion::LATEST` from the Rust `agent-client-protocol` crate. Use `LATEST_PROTOCOL_VERSION` from `@agentclientprotocol/sdk` if exported; otherwise hardcode the string. -- If `invoke("get_goose_serve_url")` fails, the error propagates to the caller. The app startup code (Step 08) handles this by showing an error state rather than crashing. -- Reconnection is passive (reset-on-close), not active (no polling/retry loop). This keeps the implementation simple while ensuring the app recovers from transient disconnections. The connection is local (same machine), so reconnection typically succeeds immediately. -- The feature flag defaults to `false` (old path). During development, enable it via browser console: `localStorage.setItem("goose2_use_direct_acp", "true")`. In production, flip the default to `true` after validation, then remove the flag entirely in Step 09. diff --git a/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md b/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md deleted file mode 100644 index c28368622a99..000000000000 --- a/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md +++ /dev/null @@ -1,315 +0,0 @@ -# Step 04: Create the TypeScript Notification Handler - -## Objective - -Port the Rust `SessionEventDispatcher` (in `src-tauri/src/services/acp/manager/dispatcher.rs`) to TypeScript. This module receives ACP `SessionNotification` events and updates Zustand stores directly — replacing the current Tauri event bus (`acp:text`, `acp:tool_call`, etc.) and the `useAcpStream` hook. - -## Why - -Currently, ACP notifications flow through three layers: -1. Rust `SessionEventDispatcher` receives the ACP callback -2. Rust emits Tauri events (`acp:text`, `acp:done`, etc.) -3. TypeScript `useAcpStream` hook listens to those events and updates stores - -By handling notifications directly in TypeScript, we eliminate the Tauri event bus intermediary and the `useAcpStream` hook entirely. - -## New File - -### `src/shared/api/acpNotificationHandler.ts` - -This file implements the `AcpNotificationHandler` interface from Step 03 and contains all the logic currently split between `dispatcher.rs`, `writer.rs`, and `useAcpStream.ts`. - -## Key Data Structures to Port - -### Session Route Map - -The Rust `dispatcher.rs` maintains a `HashMap` that maps goose session IDs to local session IDs. Port this as: - -```typescript -interface SessionRoute { - localSessionId: string; - providerId: string | null; - activeMessageId: string | null; - canceled: boolean; - personaId: string | null; - personaName: string | null; -} - -const routes = new Map(); -``` - -### Replay Buffer - -During `loadSession`, notifications arrive for historical messages. These are buffered and flushed as a single `store.setMessages()` call when `replay_complete` is signaled. - -```typescript -import type { Message, MessageContent, ToolRequestContent } from "@/shared/types/messages"; - -const replayBuffers = new Map(); -``` - -## Notification Dispatch Logic - -Port the `session_notification` method from `dispatcher.rs`. The Rust code handles these `SessionUpdate` variants: - -### 1. `SessionInfoUpdate` - -```typescript -function handleSessionInfoUpdate(localSessionId: string, info: SessionInfoUpdate): void { - const session = useChatSessionStore.getState().getSession(localSessionId); - if (info.title && !session?.userSetName) { - useChatSessionStore.getState().updateSession(localSessionId, { - title: info.title, - }, { persistOverlay: false }); - } -} -``` - -### 2. `ConfigOptionUpdate` (model state) - -Extract model options from `SessionConfigSelectOptions` (ungrouped or grouped) and update the session store: - -```typescript -import type { ModelOption } from "@/features/chat/types"; - -function extractModelOptionsFromConfigOptions( - options: SessionConfigOption[], -): { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] } | null { - const modelOption = options.find( - (opt) => opt.category === "model" - ); - if (!modelOption || modelOption.kind.type !== "select") return null; - - const select = modelOption.kind; - const currentModelId = select.currentValue; - const availableModels: ModelOption[] = []; - - if (select.options.type === "ungrouped") { - for (const value of select.options.values) { - availableModels.push({ id: value.value, name: value.name }); - } - } else if (select.options.type === "grouped") { - for (const group of select.options.groups) { - for (const value of group.options) { - availableModels.push({ id: value.value, name: value.name }); - } - } - } - - const currentModelName = availableModels.find(m => m.id === currentModelId)?.name ?? null; - return { currentModelId, currentModelName, availableModels }; -} - -function handleModelState( - localSessionId: string, - providerId: string | null, - modelState: { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] }, -): void { - const sessionStore = useChatSessionStore.getState(); - if (providerId) { - sessionStore.cacheModelsForProvider(providerId, modelState.availableModels); - } - const session = sessionStore.getSession(localSessionId); - const sessionProvider = session?.providerId; - if (providerId && sessionProvider && providerId !== sessionProvider) { - return; - } - const modelName = modelState.currentModelName ?? modelState.currentModelId; - sessionStore.setSessionModels(localSessionId, modelState.availableModels); - if (!providerId && session?.modelId) { - return; - } - sessionStore.updateSession(localSessionId, { - modelId: modelState.currentModelId, - modelName, - }, { persistOverlay: false }); -} -``` - -### 3. `AgentMessageChunk` (live streaming — text) - -When a route has an `activeMessageId` (live streaming path): - -```typescript -function handleLiveText(localSessionId: string, text: string): void { - const store = useChatStore.getState(); - store.updateStreamingText(localSessionId, text); -} -``` - -When in replay mode (no `activeMessageId`, session is loading): - -```typescript -function handleReplayText(localSessionId: string, gooseSessionId: string, text: string): void { - const buffer = replayBuffers.get(localSessionId); - if (!buffer) return; - const route = routes.get(gooseSessionId); - // Find or create the current assistant message in the buffer - // and append the text chunk to it. -} -``` - -### 4. `ToolCall` and `ToolCallUpdate` - -The live path calls: -```typescript -store.appendToStreamingMessage(sessionId, toolRequest); -``` - -The replay path appends to the buffer message. - -### 5. `UserMessageChunk` (replay only) - -During replay, user messages arrive as `UserMessageChunk`. Extract the inner content: - -```typescript -function extractUserMessage(raw: string): string { - const openTag = "\n"; - const closeTag = "\n"; - const startIdx = raw.indexOf(openTag); - if (startIdx >= 0) { - const innerStart = startIdx + openTag.length; - if (raw.substring(innerStart).endsWith(closeTag)) { - return raw.substring(innerStart, raw.length - closeTag.length); - } - } - return raw; -} -``` - -### 6. Done / Finalize - -When a streaming message completes (the `prompt()` call resolves), the session manager (Step 05) calls a finalize method: - -```typescript -export function finalizeMessage(localSessionId: string, messageId: string): void { - const store = useChatStore.getState(); - store.updateMessage(localSessionId, messageId, (message) => { - const content = message.content.map((block) => - block.type === "toolRequest" && block.status === "executing" - ? { ...block, status: "completed" as const } - : block, - ); - return { - ...message, - content, - metadata: { ...message.metadata, completionStatus: "completed" }, - }; - }); - store.setStreamingMessageId(localSessionId, null); - store.setChatState(localSessionId, "idle"); -} -``` - -## Public API - -```typescript -/** Register a goose session ID → local session ID binding. */ -export function bindSession(gooseSessionId: string, localSessionId: string, providerId?: string): void; - -/** Attach a "writer" for live streaming — sets the active message ID. */ -export function attachWriter(gooseSessionId: string, localSessionId: string, providerId: string | null, messageId: string, personaId?: string, personaName?: string): void; - -/** Clear the active writer after streaming completes. */ -export function clearWriter(gooseSessionId: string): void; - -/** Mark a session as cancelled. */ -export function markCanceled(gooseSessionId: string): boolean; - -/** Start replay buffering for a session. */ -export function startReplayBuffer(localSessionId: string): void; - -/** Finalize replay — flush buffer to store. */ -export function finalizeReplay(gooseSessionId: string): void; - -/** Flush the replay buffer for a session (called when loading completes). */ -export function flushReplayBuffer(localSessionId: string): void; - -/** Finalize a completed streaming message. */ -export function finalizeMessage(localSessionId: string, messageId: string): void; - -/** The main notification handler — implements AcpNotificationHandler from Step 03. */ -export function handleSessionNotification(notification: SessionNotification): Promise; -``` - -## Porting Checklist - -| Rust Source | Rust Function/Method | TS Equivalent | -|-------------|---------------------|---------------| -| `dispatcher.rs` | `SessionEventDispatcher::session_notification` | `handleSessionNotification()` | -| `dispatcher.rs` | `SessionEventDispatcher::bind_session` | `bindSession()` | -| `dispatcher.rs` | `SessionEventDispatcher::attach_writer` | `attachWriter()` | -| `dispatcher.rs` | `SessionEventDispatcher::clear_writer` | `clearWriter()` | -| `dispatcher.rs` | `SessionEventDispatcher::mark_canceled` | `markCanceled()` | -| `dispatcher.rs` | `SessionEventDispatcher::finalize_replay` | `finalizeReplay()` | -| `dispatcher.rs` | `SessionEventDispatcher::emit_session_info` | `handleSessionInfoUpdate()` | -| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state` | `handleModelState()` | -| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state_from_options` | `handleModelState()` via `extractModelOptionsFromConfigOptions()` | -| `dispatcher.rs` | `SessionEventDispatcher::emit_replay_complete` | `flushReplayBuffer()` + `store.setSessionLoading(false)` | -| `dispatcher.rs` | `extract_user_message` | `extractUserMessage()` | -| `dispatcher.rs` | `extract_content_preview` | `extractContentPreview()` | -| `writer.rs` | `TauriMessageWriter::append_text` | Handled inline in `handleSessionNotification` | -| `writer.rs` | `TauriMessageWriter::record_tool_call` | Handled inline in `handleSessionNotification` | -| `writer.rs` | `TauriMessageWriter::record_tool_result` | Handled inline in `handleSessionNotification` | -| `writer.rs` | `TauriMessageWriter::finalize` | `finalizeMessage()` | -| `useAcpStream.ts` | All event listeners | Replaced by `handleSessionNotification()` | -| `replayBuffer.ts` | Buffer management | Inlined or imported | - -## Store Methods Used - -The notification handler calls these existing Zustand store methods (no changes needed to the stores): - -**`useChatStore`:** -- `addMessage(sessionId, message)` -- `updateMessage(sessionId, messageId, updater)` -- `setMessages(sessionId, messages)` — for replay buffer flush -- `updateStreamingText(sessionId, text)` -- `appendToStreamingMessage(sessionId, content)` -- `setStreamingMessageId(sessionId, id)` -- `setChatState(sessionId, state)` -- `setPendingAssistantProvider(sessionId, null)` -- `setSessionLoading(sessionId, loading)` -- `markSessionUnread(sessionId)` -- `setError(sessionId, error)` - -**`useChatSessionStore`:** -- `updateSession(sessionId, patch, opts)` -- `setSessionAcpId(sessionId, acpSessionId)` -- `setSessionModels(sessionId, models)` -- `cacheModelsForProvider(providerId, models)` -- `getSession(sessionId)` - -## Registration - -During app initialization (Step 08), register the handler with the connection manager: - -```typescript -import { setNotificationHandler } from "@/shared/api/acpConnection"; -import * as notificationHandler from "@/shared/api/acpNotificationHandler"; - -setNotificationHandler(notificationHandler); -``` - -## Verification - -1. `pnpm typecheck` passes. -2. `pnpm check` passes. -3. Unit tests for `extractUserMessage` and `extractContentPreview` (port the Rust tests from `dispatcher_tests.rs`). - -## Files Created - -| File | Purpose | -|------|---------| -| `src/shared/api/acpNotificationHandler.ts` | ACP notification handler — replaces dispatcher.rs + writer.rs + useAcpStream.ts | - -## Dependencies - -- Step 03 (`acpConnection.ts` must exist for the `AcpNotificationHandler` interface) -- Zustand stores (`useChatStore`, `useChatSessionStore`) — no changes needed - -## Notes - -- In single-threaded JS, a plain `Map` replaces the Rust `Arc>` for route storage. -- Replay buffering relies on the `replay_complete` signal from the backend. The `loadSession` RPC resolves, the backend sends remaining notifications, then sends `replay_complete`. The handler flushes the buffer at that point. -- The `SessionNotification` type from `@agentclientprotocol/sdk` has a `sessionId` field (the goose session ID) and an `update` field with the variant. Check the SDK types for the exact shape. -- Port the `shouldTrackStreamingEvent` guard from `useAcpStream.ts` — it prevents stale events from updating already-completed messages. diff --git a/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md b/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md deleted file mode 100644 index e0c2f8c780ce..000000000000 --- a/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md +++ /dev/null @@ -1,465 +0,0 @@ -# Step 05: Create the TypeScript Session Manager - -## Objective - -Port session state management and ACP operations from the Rust `session_ops.rs`, `command_dispatch.rs`, and `registry.rs` to TypeScript. This module orchestrates all ACP calls: prepare session, send prompt, cancel, load, list, export, import, fork, set model, and list providers. - -## Why - -The Rust `GooseAcpManager` + `session_ops` is the core orchestration layer that: -1. Tracks which goose sessions are prepared (composite key → goose session ID) -2. Creates or loads goose sessions on demand -3. Sets provider/model/working-dir on sessions -4. Sends prompts and coordinates with the notification handler for streaming -5. Handles cancellation -6. Provides session CRUD (list, export, import, fork) - -All of this is pure protocol logic with no native OS access — it belongs in TypeScript. - -## New File - -### `src/shared/api/acpSessionManager.ts` - -## Key Data Structures - -### Prepared Session Cache - -```typescript -interface PreparedSession { - gooseSessionId: string; - providerId: string; - workingDir: string; -} - -/** Maps composite key (sessionId or sessionId__personaId) → PreparedSession */ -const preparedSessions = new Map(); -``` - -### Composite Key Helpers - -```typescript -export function makeCompositeKey(sessionId: string, personaId?: string): string { - if (personaId && personaId.length > 0) { - return `${sessionId}__${personaId}`; - } - return sessionId; -} - -export function splitCompositeKey(key: string): { sessionId: string; personaId: string | null } { - const idx = key.indexOf("__"); - if (idx >= 0) { - const personaId = key.substring(idx + 2); - if (personaId.length > 0) { - return { sessionId: key.substring(0, idx), personaId }; - } - } - return { sessionId: key, personaId: null }; -} -``` - -### Running Session Tracking - -```typescript -interface RunningSession { - compositeKey: string; - providerId: string; - startedAt: number; // Date.now() - assistantMessageId: string | null; - abortController: AbortController; -} - -const runningSessions = new Map(); -``` - -## Core Operations - -### 1. `prepareSession` - -This is the most complex function. It: - -1. Checks if a session is already prepared for this composite key -2. If yes, reuses it (updating working dir / provider if changed) -3. If no, tries to load an existing goose session by ID -4. If that fails, creates a new goose session via `client.newSession()` -5. Binds the goose session ID to the local session ID in the notification handler -6. Sets the provider via `client.setSessionConfigOption()` if needed -7. Emits model state to the session store - -```typescript -export async function prepareSession( - compositeKey: string, - localSessionId: string, - providerId: string, - workingDir: string, -): Promise { - const client = await getClient(); - - const existing = preparedSessions.get(compositeKey) ?? preparedSessions.get(localSessionId); - if (existing) { - bindSession(existing.gooseSessionId, localSessionId, providerId); - if (existing.workingDir !== workingDir) { - await client.goose.gooseWorkingDirUpdate({ sessionId: existing.gooseSessionId, workingDir }); - // ... update cache - } - if (existing.providerId !== providerId) { - const response = await client.setSessionConfigOption({ - sessionId: existing.gooseSessionId, - optionId: "provider", - value: providerId, - }); - // ... emit model state from response.configOptions - } - return existing.gooseSessionId; - } - - let gooseSessionId: string | null = null; - try { - const loadResponse = await client.loadSession({ - sessionId: localSessionId, - workingDir, - }); - gooseSessionId = localSessionId; - bindSession(gooseSessionId, localSessionId, providerId); - // ... handle model state from loadResponse - // ... update provider if needed - } catch { - // Session doesn't exist — create new - } - - if (!gooseSessionId) { - const meta: Record = {}; - if (providerId !== "goose") { - meta.provider = providerId; - } - const newResponse = await client.newSession({ - workingDir, - ...(Object.keys(meta).length > 0 ? { meta } : {}), - }); - gooseSessionId = newResponse.sessionId; - bindSession(gooseSessionId, localSessionId, providerId); - // ... handle model state from newResponse - } - - const prepared: PreparedSession = { gooseSessionId, providerId, workingDir }; - preparedSessions.set(compositeKey, prepared); - preparedSessions.set(localSessionId, prepared); - - return gooseSessionId; -} -``` - -### 2. `sendPrompt` - -```typescript -export async function sendPrompt( - sessionId: string, - providerId: string, - prompt: string, - options: { - workingDir?: string; - systemPrompt?: string; - personaId?: string; - personaName?: string; - images?: [string, string][]; // [base64, mimeType] - } = {}, -): Promise { - const client = await getClient(); - const compositeKey = makeCompositeKey(sessionId, options.personaId); - - const effectivePrompt = buildEffectivePrompt(prompt, options.systemPrompt); - - const abort = new AbortController(); - const assistantMessageId = crypto.randomUUID(); - runningSessions.set(compositeKey, { - compositeKey, - providerId, - startedAt: Date.now(), - assistantMessageId, - abortController: abort, - }); - - try { - const workingDir = options.workingDir ?? defaultArtifactsWorkingDir(); - const gooseSessionId = await prepareSession(compositeKey, sessionId, providerId, workingDir); - - attachWriter(gooseSessionId, sessionId, providerId, assistantMessageId, options.personaId, options.personaName); - - const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }]; - for (const [data, mimeType] of (options.images ?? [])) { - content.push({ type: "image", data, mimeType }); - } - - await client.prompt({ - sessionId: gooseSessionId, - content, - }); - - clearWriter(gooseSessionId); - finalizeMessage(sessionId, assistantMessageId); - } catch (error) { - clearWriter(/* gooseSessionId */); - throw error; - } finally { - runningSessions.delete(compositeKey); - } -} -``` - -### 3. `cancelSession` - -```typescript -export async function cancelSession(sessionId: string, personaId?: string): Promise { - const compositeKey = makeCompositeKey(sessionId, personaId); - const running = runningSessions.get(compositeKey); - - const prepared = preparedSessions.get(compositeKey) ?? preparedSessions.get(sessionId); - if (!prepared) { - return running !== undefined; // still preparing - } - - markCanceled(prepared.gooseSessionId); - - try { - const client = await getClient(); - await client.cancel({ sessionId: prepared.gooseSessionId }); - } catch { - // Best-effort cancellation - } - - return true; -} -``` - -### 4. `listSessions` - -```typescript -export interface AcpSessionInfo { - sessionId: string; - title: string | null; - updatedAt: string | null; - messageCount: number; -} - -export async function listSessions(): Promise { - const client = await getClient(); - const response = await client.unstable_listSessions({}); - return response.sessions.map((info) => ({ - sessionId: info.sessionId, - title: info.title ?? null, - updatedAt: info.updatedAt ?? null, - messageCount: (info.meta?.messageCount as number) ?? 0, - })); -} -``` - -### 5. `loadSession` - -```typescript -export async function loadSession( - localSessionId: string, - gooseSessionId: string, - workingDir: string, -): Promise { - const client = await getClient(); - - bindSession(gooseSessionId, localSessionId); - startReplayBuffer(localSessionId); - - const response = await client.loadSession({ - sessionId: gooseSessionId, - workingDir, - }); - - // The backend sends replay notifications asynchronously. - // The notification handler flushes the replay buffer on replay_complete. - - if (response.models) { - handleModelState(localSessionId, null, /* extract from response.models */); - } - if (response.configOptions) { - const modelState = extractModelOptionsFromConfigOptions(response.configOptions); - if (modelState) handleModelState(localSessionId, null, modelState); - } - - preparedSessions.set(localSessionId, { - gooseSessionId, - providerId: "goose", // updated on next prepare - workingDir, - }); -} -``` - -### 6. `exportSession`, `importSession`, `forkSession` - -```typescript -export async function exportSession(sessionId: string): Promise { - const client = await getClient(); - const result = await client.goose.gooseSessionExport({ sessionId }); - return result.data; -} - -export async function importSession(json: string): Promise { - const client = await getClient(); - return await client.goose.gooseSessionImport({ data: json }); -} - -export async function forkSession(sessionId: string): Promise { - const client = await getClient(); - const response = await client.unstable_forkSession({ - sessionId, - workingDir: defaultArtifactsWorkingDir(), - }); - return { - sessionId: response.sessionId, - title: (response.meta?.title as string) ?? null, - updatedAt: null, - messageCount: (response.meta?.messageCount as number) ?? 0, - }; -} -``` - -### 7. `setModel` - -```typescript -export async function setModel(localSessionId: string, modelId: string): Promise { - const client = await getClient(); - - for (const [key, prepared] of preparedSessions) { - const { sessionId } = splitCompositeKey(key); - if (sessionId !== localSessionId) continue; - - const response = await client.setSessionConfigOption({ - sessionId: prepared.gooseSessionId, - optionId: "model", - value: modelId, - }); - const modelState = extractModelOptionsFromConfigOptions(response.configOptions); - if (modelState) handleModelState(localSessionId, prepared.providerId, modelState); - } -} -``` - -### 8. `listProviders` - -```typescript -const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]); - -export interface AcpProvider { - id: string; - label: string; -} - -export async function listProviders(): Promise { - const client = await getClient(); - const result = await client.goose.gooseProvidersList({}); - return result.providers - .filter((p: { id: string }) => !DEPRECATED_PROVIDER_IDS.has(p.id)) - .map((p: { id: string; label: string }) => ({ id: p.id, label: p.label })); -} -``` - -### 9. `listRunning` - -```typescript -export interface AcpRunningSession { - sessionId: string; - personaId: string | null; - providerId: string; - runningForSecs: number; -} - -export function listRunning(): AcpRunningSession[] { - const now = Date.now(); - return [...runningSessions.values()].map((entry) => { - const { sessionId, personaId } = splitCompositeKey(entry.compositeKey); - return { - sessionId, - personaId, - providerId: entry.providerId, - runningForSecs: Math.floor((now - entry.startedAt) / 1000), - }; - }); -} -``` - -### 10. `cancelAll` - -```typescript -export function cancelAll(): void { - for (const entry of runningSessions.values()) { - entry.abortController.abort(); - } -} -``` - -## Helper: Build Effective Prompt - -```typescript -function buildEffectivePrompt(prompt: string, systemPrompt?: string): string { - if (!systemPrompt || systemPrompt.trim().length === 0) { - return prompt; - } - return [ - `\n${systemPrompt}\n`, - `\n${prompt}\n`, - ].join("\n\n"); -} -``` - -## Helper: Default Artifacts Working Dir - -```typescript -function defaultArtifactsWorkingDir(): string { - return "~/.goose/artifacts"; -} -``` - -The `goose serve` backend handles working directory resolution and `~` expansion. This function only needs to supply a reasonable path. - -## Imports from Other Modules - -```typescript -import { getClient } from "./acpConnection"; -import { - bindSession, - attachWriter, - clearWriter, - markCanceled, - startReplayBuffer, - finalizeReplay, - flushReplayBuffer, - finalizeMessage, - handleModelState, - extractModelOptionsFromConfigOptions, -} from "./acpNotificationHandler"; -``` - -## Concurrency - -The Rust code uses per-session `Mutex` locks (`op_locks`) and `pending_cancels` / `preparing_sessions` sets to prevent concurrent mutations and coordinate cancellation during preparation. In single-threaded JS, mutex locks aren't needed for correctness, but a simple promise-based lock prevents concurrent `prepareSession` calls for the same composite key from racing. Port `pending_cancels` and `preparing_sessions` as module-level `Set` variables. - -## Generated Client Method Names - -The `GooseExtClient` methods (e.g., `client.goose.gooseProvidersList()`) are generated from the ACP schema. Verify actual method names in `ui/acp/src/generated/client.gen.ts` — they use camelCase versions of the `goose/providers/list` method name. - -## Streaming Model - -The `client.prompt()` call blocks until the agent finishes responding. During this time, `SessionNotification` events stream in via the `Client` callback, handled by the notification handler. This matches the Rust flow. - -## Verification - -1. `pnpm typecheck` passes. -2. `pnpm check` passes. -3. Unit tests for `makeCompositeKey`, `splitCompositeKey`, `buildEffectivePrompt`. -4. Port relevant tests from `session_ops/tests.rs`. - -## Files Created - -| File | Purpose | -|------|---------| -| `src/shared/api/acpSessionManager.ts` | Session state management and ACP operations | - -## Dependencies - -- Step 03 (`acpConnection.ts` — provides `getClient()`) -- Step 04 (`acpNotificationHandler.ts` — provides bind/attach/clear/finalize functions) diff --git a/ui/goose2/acp-plus-migration-plan/06-port-session-search.md b/ui/goose2/acp-plus-migration-plan/06-port-session-search.md deleted file mode 100644 index 7af063fcd8d2..000000000000 --- a/ui/goose2/acp-plus-migration-plan/06-port-session-search.md +++ /dev/null @@ -1,357 +0,0 @@ -# Step 06: Port Session Content Search to TypeScript - -## Objective - -Port the session content search logic to TypeScript. This is pure text processing on exported JSON and requires no native access. - -## Why - -The search code: -1. Exports each session as JSON via the ACP `goose/session/export` extension method -2. Parses the JSON to extract user/assistant/system messages -3. Performs case-insensitive substring matching -4. Builds snippets around the first match - -All of this is string processing that runs fine in JavaScript. Moving it to TypeScript eliminates the native round-trip for each session export during search. - -## New File - -### `src/features/sessions/lib/sessionContentSearch.ts` - -```typescript -/** - * Search session message content via exported Goose sessions. - */ -import { exportSession } from "@/shared/api/acpSessionManager"; // from Step 05 - -const SNIPPET_PREFIX_BYTES = 40; -const SNIPPET_SUFFIX_BYTES = 60; - -export interface SessionSearchResult { - sessionId: string; - snippet: string; - messageId: string; - messageRole?: "user" | "assistant" | "system"; - matchCount: number; -} -``` - -## Functions - -### 1. `searchSessionsViaExports` - -Top-level function that iterates over session IDs, exports each, and searches: - -```typescript -export async function searchSessionsViaExports( - query: string, - sessionIds: string[], -): Promise { - const trimmed = query.trim(); - if (!trimmed) return []; - - const seen = new Set(); - const results: SessionSearchResult[] = []; - - for (const sessionId of sessionIds) { - if (seen.has(sessionId)) continue; - seen.add(sessionId); - - try { - const exported = await exportSession(sessionId); - const result = searchExportedSession(sessionId, exported, trimmed); - if (result) results.push(result); - } catch { - // Skip sessions that fail to export - } - } - - return results; -} -``` - -### 2. `searchExportedSession` - -```typescript -function searchExportedSession( - sessionId: string, - exportedJson: string, - query: string, -): SessionSearchResult | null { - let root: unknown; - try { - root = JSON.parse(exportedJson); - } catch { - return null; - } - - const obj = root as Record; - const conversation = obj.conversation ?? obj.messages; - if (!conversation) return null; - - const messages = extractMessages(conversation); - if (messages.length === 0) return null; - - let firstMatch: { messageId: string; role: string | null; snippet: string } | null = null; - let matchCount = 0; - - for (const message of messages) { - for (const text of message.searchableTexts) { - const occurrences = countOccurrences(text, query); - if (occurrences === 0) continue; - - matchCount += occurrences; - - if (!firstMatch) { - firstMatch = { - messageId: message.id, - role: message.role, - snippet: buildSnippet(text, query), - }; - } - } - } - - if (!firstMatch) return null; - - return { - sessionId, - snippet: firstMatch.snippet, - messageId: firstMatch.messageId, - messageRole: firstMatch.role as SessionSearchResult["messageRole"], - matchCount, - }; -} -``` - -### 3. `extractMessages` - -Recursively walks the JSON structure to find message objects: - -```typescript -interface ExportedMessage { - id: string; - role: string | null; - searchableTexts: string[]; -} - -function extractMessages(value: unknown): ExportedMessage[] { - const messages: ExportedMessage[] = []; - collectMessages(value, messages); - return messages; -} - -function collectMessages(value: unknown, messages: ExportedMessage[]): void { - if (Array.isArray(value)) { - for (const item of value) { - collectMessages(item, messages); - } - return; - } - - if (typeof value !== "object" || value === null) return; - const obj = value as Record; - - if (obj.message !== undefined) { - collectMessages(obj.message, messages); - return; - } - - if (obj.messages !== undefined) { - collectMessages(obj.messages, messages); - return; - } - - if (looksLikeMessage(obj)) { - const fallbackId = `message-${messages.length}`; - const message = extractMessage(obj, fallbackId); - if (message) messages.push(message); - } -} - -function looksLikeMessage(obj: Record): boolean { - return "role" in obj && ("content" in obj || "text" in obj); -} -``` - -### 4. `extractMessage` - -```typescript -function extractMessage( - obj: Record, - fallbackId: string, -): ExportedMessage | null { - const role = normalizeRole(obj.role as string | undefined); - const searchableTexts: string[] = []; - - if (obj.content !== undefined) { - searchableTexts.push(...extractSearchableTexts(obj.content, role)); - } else if (typeof obj.text === "string") { - if (role && obj.text.trim().length > 0) { - searchableTexts.push(obj.text.trim()); - } - } - - if (searchableTexts.length === 0) return null; - - return { - id: typeof obj.id === "string" ? obj.id : fallbackId, - role, - searchableTexts, - }; -} -``` - -### 5. `extractSearchableTexts` - -```typescript -function extractSearchableTexts(value: unknown, role: string | null): string[] { - if (typeof value === "string") { - if (role && isSearchableRole(role)) { - const trimmed = value.trim(); - return trimmed.length > 0 ? [trimmed] : []; - } - return []; - } - - if (Array.isArray(value)) { - return value.flatMap((item) => extractSearchableBlockText(item, role)); - } - - if (typeof value === "object" && value !== null) { - return extractSearchableBlockText(value, role); - } - - return []; -} - -function extractSearchableBlockText(value: unknown, role: string | null): string[] { - if (typeof value !== "object" || value === null) return []; - const obj = value as Record; - - const blockType = obj.type as string | undefined; - const text = obj.text as string | undefined; - - switch (blockType) { - case "text": - case "input_text": - case "output_text": - case "systemNotification": - case "system_notification": { - const trimmed = text?.trim(); - return trimmed && trimmed.length > 0 ? [trimmed] : []; - } - case "toolRequest": - case "toolResponse": - case "thinking": - case "redactedThinking": - case "reasoning": - case "image": - return []; - default: { - if (role && isSearchableRole(role)) { - const trimmed = text?.trim(); - return trimmed && trimmed.length > 0 ? [trimmed] : []; - } - return []; - } - } -} -``` - -### 6. Helper Functions - -```typescript -function normalizeRole(role: string | undefined): string | null { - if (!role) return null; - const trimmed = role.trim().toLowerCase(); - if (trimmed === "user") return "user"; - if (trimmed === "assistant") return "assistant"; - if (trimmed === "system") return "system"; - return null; -} - -function isSearchableRole(role: string): boolean { - return role === "user" || role === "assistant" || role === "system"; -} - -function countOccurrences(text: string, query: string): number { - const haystack = text.toLowerCase(); - const needle = query.toLowerCase(); - if (needle.length === 0) return 0; - - let count = 0; - let searchStart = 0; - - while (true) { - const index = haystack.indexOf(needle, searchStart); - if (index === -1) break; - count += 1; - searchStart = index + needle.length; - } - - return count; -} - -function buildSnippet(text: string, query: string): string { - const haystack = text.toLowerCase(); - const needle = query.toLowerCase(); - const matchIndex = haystack.indexOf(needle); - const effectiveMatchIndex = matchIndex >= 0 ? matchIndex : 0; - - const start = Math.max(0, effectiveMatchIndex - SNIPPET_PREFIX_BYTES); - const end = Math.min( - text.length, - effectiveMatchIndex + query.length + SNIPPET_SUFFIX_BYTES, - ); - - const prefix = start > 0 ? "..." : ""; - const suffix = end < text.length ? "..." : ""; - const body = text.substring(start, end).trim(); - - return `${prefix}${body}${suffix}`; -} -``` - -## Tests - -### `src/features/sessions/lib/__tests__/sessionContentSearch.test.ts` - -```typescript -import { describe, it, expect } from "vitest"; - -describe("sessionContentSearch", () => { - it("finds user and assistant text matches", () => { /* ... */ }); - it("includes system notifications", () => { /* ... */ }); - it("skips tool and reasoning content", () => { /* ... */ }); - it("counts multiple matches in one session", () => { /* ... */ }); - it("builds trimmed snippets around first match", () => { /* ... */ }); -}); -``` - -## Integration with `useSessionSearch` - -The existing `useSessionSearch` hook calls `acpSearchSessions()` from `@/shared/api/acp`. In Step 07, that function will be rewired to call `searchSessionsViaExports` from this module instead of `invoke("acp_search_sessions")`. - -## Verification - -1. `pnpm typecheck` passes. -2. `pnpm check` passes. -3. `pnpm test` — all search tests pass. - -## Files Created - -| File | Purpose | -|------|---------| -| `src/features/sessions/lib/sessionContentSearch.ts` | Session content search logic | -| `src/features/sessions/lib/__tests__/sessionContentSearch.test.ts` | Tests | - -## Dependencies - -- Step 05 (`acpSessionManager.ts` — provides `exportSession()`) - -## Notes - -- In JavaScript, `String.substring()` operates on UTF-16 code units and is already safe for slicing. Snippet boundaries may differ slightly for multi-byte characters, but this is cosmetic. -- The search is sequential (one session at a time). Parallelization via `Promise.all` is a future optimization if search latency becomes a problem. -- The `exportSession` call goes through the ACP client to `goose serve`, which reads from its database. There is no change in data source. diff --git a/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md b/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md deleted file mode 100644 index d3a40801906b..000000000000 --- a/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md +++ /dev/null @@ -1,237 +0,0 @@ -# Step 07: Rewire `src/shared/api/acp.ts` to Use the TypeScript ACP Client - -## Objective - -Replace all `invoke()` calls in `src/shared/api/acp.ts` with calls to the TypeScript ACP session manager (Step 05) and search module (Step 06). Keep the same public API signatures so consumers don't need to change. Use the feature flag from Step 03 to route between old and new paths. - -## Why - -`src/shared/api/acp.ts` is the single import point for all ACP operations in the frontend. Currently every function calls `invoke("acp_*")`, which goes through Tauri IPC → Rust → WebSocket → goose serve. After this step, they call the TypeScript session manager, which goes directly through WebSocket → goose serve. - -The feature flag (`useDirectAcp`) allows both paths to coexist. This means: -- The swap is gradual and reversible -- We can test the new path per-user without affecting everyone -- Instant rollback by flipping the flag - -## Changes - -### `src/shared/api/acp.ts` - -Keep the existing `invoke()` implementations. Add the new direct-ACP implementations alongside them. Route via the feature flag. - -**Pattern:** -```typescript -import { invoke } from "@tauri-apps/api/core"; -import { useDirectAcp } from "./acpFeatureFlag"; - -// Lazy imports to avoid loading the new modules when flag is off -async function getSessionManager() { - return import("./acpSessionManager"); -} - -export async function discoverAcpProviders(): Promise { - if (useDirectAcp()) { - const { listProviders } = await getSessionManager(); - return listProviders(); - } - return invoke("discover_acp_providers"); -} -``` - -Once validated, a follow-up removes the `invoke()` branches and the feature flag (Step 09). - -### Function-by-function rewiring - -#### `discoverAcpProviders` - -```typescript -export async function discoverAcpProviders(): Promise { - if (useDirectAcp()) { - const { listProviders } = await getSessionManager(); - return listProviders(); - } - return invoke("discover_acp_providers"); -} -``` - -#### `acpSendMessage` - -```typescript -export async function acpSendMessage( - sessionId: string, - providerId: string, - prompt: string, - options: AcpSendMessageOptions = {}, -): Promise { - return sendPrompt(sessionId, providerId, prompt, { - workingDir: options.workingDir, - systemPrompt: options.systemPrompt, - personaId: options.personaId, - personaName: options.personaName, - images: options.images, - }); -} -``` - -#### `acpPrepareSession` - -```typescript -export async function acpPrepareSession( - sessionId: string, - providerId: string, - options: AcpPrepareSessionOptions = {}, -): Promise { - const { makeCompositeKey } = await import("./acpSessionManager"); - const compositeKey = makeCompositeKey(sessionId, options.personaId); - const workingDir = options.workingDir ?? "~/.goose/artifacts"; - await prepareSession(compositeKey, sessionId, providerId, workingDir); -} -``` - -#### `acpSetModel` - -```typescript -export async function acpSetModel( - sessionId: string, - modelId: string, -): Promise { - return setModel(sessionId, modelId); -} -``` - -#### `acpListSessions` - -```typescript -export async function acpListSessions(): Promise { - return listSessions(); -} -``` - -#### `acpSearchSessions` - -```typescript -export async function acpSearchSessions( - query: string, - sessionIds: string[], -): Promise { - return searchSessionsViaExports(query, sessionIds); -} -``` - -#### `acpLoadSession` - -```typescript -export async function acpLoadSession( - sessionId: string, - gooseSessionId: string, - workingDir?: string, -): Promise { - return loadSession(sessionId, gooseSessionId, workingDir ?? "~/.goose/artifacts"); -} -``` - -#### `acpExportSession` - -```typescript -export async function acpExportSession(sessionId: string): Promise { - return exportSession(sessionId); -} -``` - -#### `acpImportSession` - -```typescript -export async function acpImportSession(json: string): Promise { - return importSession(json); -} -``` - -#### `acpDuplicateSession` - -```typescript -export async function acpDuplicateSession(sessionId: string): Promise { - return forkSession(sessionId); -} -``` - -#### `acpCancelSession` - -```typescript -export async function acpCancelSession( - sessionId: string, - personaId?: string, -): Promise { - return cancelSession(sessionId, personaId); -} -``` - -### Interface types - -`AcpSendMessageOptions` and `AcpPrepareSessionOptions` remain defined in this file since they are specific to this API surface. Types originating from the session manager and search module are re-exported: - -```typescript -export type { AcpProvider, AcpSessionInfo } from "./acpSessionManager"; -export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch"; - -export interface AcpSendMessageOptions { - systemPrompt?: string; - workingDir?: string; - personaId?: string; - personaName?: string; - images?: [string, string][]; -} - -export interface AcpPrepareSessionOptions { - workingDir?: string; - personaId?: string; -} -``` - -### `src/shared/api/index.ts` - -No changes needed — it already re-exports from `./acp`: - -```typescript -export * from "./acp"; -``` - -## Consumers - -These files import from `@/shared/api/acp` and require no changes since the public API is unchanged: - -| File | Imports Used | -|------|-------------| -| `src/features/chat/hooks/useChat.ts` | `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` | -| `src/features/chat/stores/chatSessionStore.ts` | `acpListSessions`, `AcpSessionInfo` | -| `src/features/sessions/hooks/useSessionSearch.ts` | `acpSearchSessions` | -| `src/features/sessions/lib/buildSessionSearchResults.ts` | `AcpSessionSearchResult` | -| `src/app/AppShell.tsx` | `acpPrepareSession`, `acpLoadSession` | -| `src/app/hooks/useAppStartup.ts` | `discoverAcpProviders` | - -## Remove `invoke` Import - -The file no longer imports from `@tauri-apps/api/core`. Other files (agents, git, system, etc.) still use `invoke()` for non-ACP commands. - -## Verification - -1. `pnpm typecheck` passes — all consumers type-check against the same API. -2. `pnpm check` passes. -3. `pnpm test` passes — existing tests that mock `invoke()` need updating (check `src/features/chat/hooks/__tests__/useAcpStream.test.ts` and `src/features/chat/hooks/__tests__/useChat.test.ts`). -4. Manual testing: start the app, confirm sessions load, messages send, and search works. - -## Files Modified - -| File | Change | -|------|--------| -| `src/shared/api/acp.ts` | Replace all `invoke()` calls with session manager / search calls | - -## Dependencies - -- Step 05 (`acpSessionManager.ts` — all session operations) -- Step 06 (`sessionContentSearch.ts` — search) - -## Notes - -- After this step, the frontend no longer calls any `acp_*` Tauri commands. The only remaining Tauri invoke for ACP infrastructure is `get_goose_serve_url`, called by `acpConnection.ts`. -- The old Rust ACP commands still exist and are registered but are no longer called. They are removed in Step 09. -- The `@tauri-apps/api/core` import is removed from this file entirely. diff --git a/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md b/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md deleted file mode 100644 index 64a7800094ba..000000000000 --- a/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md +++ /dev/null @@ -1,251 +0,0 @@ -# Step 08: Remove `useAcpStream`, Update Hooks and App Initialization - -## Objective - -Remove the `useAcpStream` hook (which listens to Tauri events) since the notification handler (Step 04) now updates stores directly. Update app initialization to set up the new ACP connection and notification handler. Update `AppShell` to use the new code paths. - -## Why - -With the notification handler updating Zustand stores directly from ACP callbacks, the Tauri event bus is no longer in the loop. The `useAcpStream` hook — which listens to `acp:text`, `acp:done`, `acp:tool_call`, etc. — is now dead code. - -## Changes - -### 1. Remove `useAcpStream` from `AppShell` - -**File:** `src/app/AppShell.tsx` - -Remove the import and call: - -```diff -- import { useAcpStream } from "@/features/chat/hooks/useAcpStream"; - - // Inside the component: -- useAcpStream(true); -``` - -### 2. Initialize the ACP connection and notification handler on startup - -**File:** `src/app/hooks/useAppStartup.ts` - -Add ACP initialization as the first step. The notification handler must be registered before any ACP calls so that session notifications from `loadSessions` are handled. - -```typescript -import { useEffect } from "react"; -import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; - -export function useAppStartup() { - useEffect(() => { - (async () => { - // Step 1: Initialize ACP connection and notification handler. - // This must happen before any ACP calls. - try { - const { getClient, setNotificationHandler } = await import( - "@/shared/api/acpConnection" - ); - const notificationHandler = await import( - "@/shared/api/acpNotificationHandler" - ); - setNotificationHandler(notificationHandler); - - // Trigger connection initialization (fetches URL, creates client, handshake). - // This blocks until goose serve is ready. - await getClient(); - } catch (err) { - console.error("Failed to initialize ACP connection:", err); - // The app can still show the UI, but ACP operations will fail. - // Individual operations will retry getClient() and show errors. - } - - // Step 2: Load data in parallel (same as before, but now using the TS ACP client) - const store = useAgentStore.getState(); - - const loadPersonas = async () => { - store.setPersonasLoading(true); - try { - const { listPersonas } = await import("@/shared/api/agents"); - const personas = await listPersonas(); - store.setPersonas(personas); - } catch (err) { - console.error("Failed to load personas on startup:", err); - } finally { - store.setPersonasLoading(false); - } - }; - - const loadProviders = async () => { - store.setProvidersLoading(true); - try { - const { discoverAcpProviders } = await import("@/shared/api/acp"); - const providers = await discoverAcpProviders(); - store.setProviders(providers); - } catch (err) { - console.error("Failed to load ACP providers on startup:", err); - } finally { - store.setProvidersLoading(false); - } - }; - - const loadSessionState = async () => { - const t0 = performance.now(); - console.log("[perf:startup] loadSessionState start"); - const { loadSessions, setActiveSession } = - useChatSessionStore.getState(); - await loadSessions(); - console.log( - `[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`, - ); - setActiveSession(null); - }; - - await Promise.allSettled([ - loadPersonas(), - loadProviders(), - loadSessionState(), - ]); - })(); - }, []); -} -``` - -### 3. `AppShell.loadSessionMessages` — no changes needed - -**File:** `src/app/AppShell.tsx` - -The `loadSessionMessages` callback dynamically imports `acpLoadSession` from `@/shared/api/acp`. This still works because Step 07 rewired that function to go through the TS session manager. - -```typescript -const { acpLoadSession } = await import("@/shared/api/acp"); -``` - -### 4. `useChat` — no changes needed - -**File:** `src/features/chat/hooks/useChat.ts` - -This hook imports `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` from `@/shared/api/acp`. Step 07 kept the same API surface, so no changes are needed. - -```typescript -import { - acpSendMessage, - acpCancelSession, - acpPrepareSession, - acpSetModel, -} from "@/shared/api/acp"; -``` - -### 5. Handle app shutdown - -**File:** `src/app/AppShell.tsx` or `src/app/App.tsx` - -Add cleanup on window close to cancel running sessions: - -```typescript -import { useEffect } from "react"; - -useEffect(() => { - const handleBeforeUnload = () => { - import("@/shared/api/acpSessionManager").then(({ cancelAll }) => { - cancelAll(); - }).catch(() => {}); - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); -}, []); -``` - -The Rust backend's `acp_registry_for_exit.cancel_all()` on `RunEvent::Exit` becomes a no-op after migration (no sessions are registered in the Rust registry). The TS cleanup above replaces it. - -### 6. Delete old files - -These files are no longer needed: - -- `src/features/chat/hooks/useAcpStream.ts` — replaced by `acpNotificationHandler.ts` -- `src/features/chat/hooks/acpStreamTypes.ts` — types moved to the notification handler / SDK imports -- `src/features/chat/hooks/replayBuffer.ts` — logic moved into the notification handler -- `src/features/chat/hooks/useSSE.ts` — only consumer was `useAcpStream` - -### 7. Update test files - -**Delete:** -- `src/features/chat/hooks/__tests__/useAcpStream.test.ts` — the hook no longer exists - -**Update:** -- `src/features/chat/hooks/__tests__/useChat.test.ts` — mocks should target the session manager functions instead of `invoke()`. - -Replace any `invoke()`-level mocks: - -```typescript -// Before -vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn(), -})); - -// After -vi.mock("@/shared/api/acp", () => ({ - acpSendMessage: vi.fn().mockResolvedValue(undefined), - acpPrepareSession: vi.fn().mockResolvedValue(undefined), - acpSetModel: vi.fn().mockResolvedValue(undefined), - acpCancelSession: vi.fn().mockResolvedValue(true), -})); -``` - -### 8. Remove Tauri event listener cleanup - -The `useAcpStream` hook registered listeners for `acp:text`, `acp:done`, `acp:tool_call`, `acp:tool_title`, `acp:tool_result`, `acp:message_created`, `acp:session_info`, `acp:session_bound`, `acp:model_state`, `acp:usage_update`, `acp:replay_complete`, `acp:replay_user_message`. All of these are gone now. - -Confirm no other code listens to these events: - -```bash -cd ui/goose2/src -rg "acp:" --include="*.ts" --include="*.tsx" | grep -v "__tests__" | grep -v "node_modules" -``` - -After this step, the only `acp:` references should be in test files (updated/deleted above). - -## Verification - -1. `pnpm typecheck` passes. -2. `pnpm check` passes. -3. `pnpm test` passes (after updating/deleting affected tests). -4. Manual testing: - - App starts and shows the home screen - - Session list loads - - Creating a new chat and sending a message works - - Streaming text appears in real-time - - Tool calls display correctly - - Cancelling a running session works - - Loading a historical session replays messages - - Session search returns results - - Model switching works - - Session export/import/duplicate works - -## Files Modified - -| File | Change | -|------|--------| -| `src/app/AppShell.tsx` | Remove `useAcpStream(true)`, add shutdown cleanup | -| `src/app/hooks/useAppStartup.ts` | Add ACP connection + notification handler initialization | - -## Files Deleted - -| File | Reason | -|------|--------| -| `src/features/chat/hooks/useAcpStream.ts` | Replaced by `acpNotificationHandler.ts` | -| `src/features/chat/hooks/acpStreamTypes.ts` | Types moved to notification handler | -| `src/features/chat/hooks/replayBuffer.ts` | Logic moved to notification handler | -| `src/features/chat/hooks/useSSE.ts` | Only consumer was `useAcpStream` | -| `src/features/chat/hooks/__tests__/useAcpStream.test.ts` | Hook deleted | - -## Dependencies - -- Step 03 (`acpConnection.ts`) -- Step 04 (`acpNotificationHandler.ts`) -- Step 07 (rewired `acp.ts`) - -## Notes - -- `useAcpStream` was the only consumer of the `acp:*` Tauri events. Once removed, no frontend code listens to those events. The Rust backend still emits them until Step 09 removes the Rust code, but they go nowhere — this is harmless. -- The `useChat` hook's `sendMessage` function sets `chatState` to `"thinking"` before `acpPrepareSession`, then `"streaming"` before `acpSendMessage`. This flow is unchanged — the session manager handles the ACP calls, and the notification handler updates the store as streaming events arrive. -- The `stopGeneration` function in `useChat` calls `acpCancelSession`, which now goes through the TS session manager calling `client.cancel()` directly. -- The `loadSessionMessages` callback in `AppShell` sets `store.setSessionLoading(sessionId, true)` before calling `acpLoadSession`. The notification handler's replay logic checks `loadingSessionIds` to decide whether to buffer. This flow is preserved — the notification handler reads from `useChatStore.getState().loadingSessionIds` just as `useAcpStream` did. diff --git a/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md b/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md deleted file mode 100644 index a25809e8d77d..000000000000 --- a/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md +++ /dev/null @@ -1,341 +0,0 @@ -# Step 09: Delete the Rust ACP Middleware and Unused Dependencies - -## Objective - -Remove all Rust ACP protocol handling code that is no longer called by the frontend. This is the cleanup step — only do this after Steps 01–08 are working and tested. - -## Why - -After Steps 01–08, the frontend communicates directly with `goose serve` via WebSocket. The Rust ACP middleware (WebSocket bridge, session dispatcher, message writer, session registry, search) is dead code. Removing it: - -- Eliminates ~3,500 lines of Rust -- Removes 5–6 heavy crate dependencies -- Reduces compile times -- Simplifies the codebase - -## Changes - -### 1. Delete the ACP manager subtree - -**Delete these files entirely:** - -``` -src-tauri/src/services/acp/manager/ - command_dispatch.rs - dispatcher.rs - dispatcher_tests.rs - session_ops.rs - session_ops/ - prompt_ops.rs - tests.rs - thread.rs -``` - -**Delete these files:** - -``` -src-tauri/src/services/acp/manager.rs -src-tauri/src/services/acp/writer.rs -src-tauri/src/services/acp/payloads.rs -src-tauri/src/services/acp/registry.rs -src-tauri/src/services/acp/search.rs -``` - -### 2. Simplify `services/acp/mod.rs` - -**File:** `src-tauri/src/services/acp/mod.rs` - -Replace the entire file with: - -```rust -pub(crate) mod goose_serve; - -pub(crate) use goose_serve::GooseServeProcess; -``` - -All the old re-exports (`GooseAcpManager`, `AcpSessionRegistry`, `TauriMessageWriter`, `search_sessions_via_exports`, `make_composite_key`, `split_composite_key`, `AcpService`, `AcpRunningSession`, `AcpSessionInfo`, `SessionSearchResult`) are removed. - -### 3. Simplify `commands/acp.rs` - -**File:** `src-tauri/src/commands/acp.rs` - -Replace the entire file with just the URL command: - -```rust -use crate::services::acp::GooseServeProcess; - -/// Return the WebSocket URL of the running goose serve process. -/// -/// This command blocks until the server is confirmed ready. The frontend -/// uses this URL to establish a direct WebSocket ACP connection. -#[tauri::command] -pub async fn get_goose_serve_url() -> Result { - GooseServeProcess::start().await?; - let process = GooseServeProcess::get()?; - Ok(process.ws_url()) -} -``` - -All other ACP commands are deleted: -- `discover_acp_providers` -- `acp_prepare_session` -- `acp_set_model` -- `acp_send_message` -- `acp_cancel_session` -- `acp_list_sessions` -- `acp_search_sessions` -- `acp_load_session` -- `acp_list_running` -- `acp_cancel_all` -- `acp_export_session` -- `acp_import_session` -- `acp_duplicate_session` - -Also delete the helper functions that were only used by those commands: -- `AcpProviderResponse` struct -- `should_include_provider` -- `default_artifacts_working_dir` -- `expand_home_dir` -- `resolve_working_dir` -- The `#[cfg(test)] mod tests` block - -### 4. Update `lib.rs` - -**File:** `src-tauri/src/lib.rs` - -Remove the `AcpSessionRegistry` from managed state and remove all old ACP command registrations. - -**Before:** -```rust -use std::sync::Arc; -use services::acp::AcpSessionRegistry; - -// ... -let acp_registry = Arc::new(AcpSessionRegistry::new()); -let acp_registry_for_exit = Arc::clone(&acp_registry); - -let builder = tauri::Builder::default() - // ... - .manage(acp_registry); - -// In invoke_handler: -commands::acp::discover_acp_providers, -commands::acp::acp_prepare_session, -commands::acp::acp_set_model, -commands::acp::acp_send_message, -commands::acp::acp_cancel_session, -commands::acp::acp_list_sessions, -commands::acp::acp_search_sessions, -commands::acp::acp_load_session, -commands::acp::acp_list_running, -commands::acp::acp_cancel_all, -commands::acp::acp_export_session, -commands::acp::acp_import_session, -commands::acp::acp_duplicate_session, - -// In run closure: -.run(move |_app, event| { - if let tauri::RunEvent::Exit = event { - acp_registry_for_exit.cancel_all(); - } -}); -``` - -**After:** -```rust -// Remove: use std::sync::Arc; -// Remove: use services::acp::AcpSessionRegistry; - -// Remove: let acp_registry = ... -// Remove: let acp_registry_for_exit = ... - -let builder = tauri::Builder::default() - // ... - // Remove: .manage(acp_registry) - ; - -// In invoke_handler, replace all old ACP commands with just: -commands::acp::get_goose_serve_url, - -// Simplify the run closure: -.run(|_app, _event| {}); -``` - -The `Arc` import can be removed — `PersonaStore` and `GooseConfig` use `tauri::State` which handles the wrapping. - -### 5. Clean up `goose_serve.rs` - -**File:** `src-tauri/src/services/acp/goose_serve.rs` - -1. Remove the `WS_BRIDGE_BUFFER_BYTES` constant (only used by the deleted `thread.rs`): -```rust -// DELETE: -pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024; -``` - -2. Keep `resolve_goose_binary` exported as `pub(crate)` — it is still needed by `model_setup.rs` (which runs `goose configure`). - -3. Replace the WebSocket readiness probe with a TCP connect check. This eliminates the `tokio-tungstenite` and `futures` dependencies: - -```rust -async fn wait_for_server_ready(port: u16, child: &mut Child) -> Result<(), String> { - let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT; - let addr = format!("{LOCALHOST}:{port}"); - - loop { - match tokio::net::TcpStream::connect(&addr).await { - Ok(_) => return Ok(()), - Err(_) => { - if let Some(status) = child - .try_wait() - .map_err(|e| format!("Failed to poll goose serve process: {e}"))? - { - return Err(format!( - "Goose serve exited before becoming ready: {status}" - )); - } - - if Instant::now() >= deadline { - return Err(format!( - "Timed out waiting for goose serve on port {port}" - )); - } - - tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await; - } - } - } -} -``` - -Update the `spawn` method to call `wait_for_server_ready(port, &mut child)` instead of `wait_for_server_ready(&ws_url, &mut child)`. - -### 6. Handle `acp-client` binary discovery - -The `acp-client` crate is used by `goose_serve.rs` for `acp_client::find_acp_agent_by_id("goose")` in binary resolution. Two options: - -- **Option A (simplest):** Keep `acp-client` solely for binary discovery. It only uses the `find_acp_agent_by_id` function. -- **Option B:** Inline the discovery logic — look for `goose` on PATH and check the `GOOSE_BIN` env var. The `GOOSE_BIN` path is already handled; the `find_acp_agent_by_id` fallback scans the login shell PATH, which can be replaced with a simple `which goose` equivalent. - -Choose one approach and apply it consistently. - -### 7. Remove unused Cargo dependencies - -**File:** `src-tauri/Cargo.toml` - -Remove these dependencies: - -```toml -agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] } -tokio-tungstenite = "0.21.0" -async-trait = "0.1" -futures = "0.3" -tokio-util = { version = "0.7", features = ["compat", "rt"] } -``` - -If Option A from §6 is chosen, keep `acp-client`. If Option B is chosen, also remove: - -```toml -acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" } -``` - -After all removals, the remaining dependencies should be: - -```toml -[dependencies] -tauri = { version = "2", features = ["protocol-asset"] } -tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" } -tauri-plugin-opener = "2" -tauri-plugin-dialog = ">=2,<2.7" -tauri-plugin-window-state = "2" -tauri-plugin-log = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -dirs = "6.0.0" -log = "0.4.29" -tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "1", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } -serde_yaml = "0.9" -etcetera = "0.8" -ignore = "0.4.25" -doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" } -keyring = { ... } # platform-specific -``` - -Run `cargo check` after editing `Cargo.toml` to verify nothing breaks. - -### 8. Run full verification - -```bash -cd ui/goose2/src-tauri - -cargo fmt -cargo check -cargo clippy --all-targets -- -D warnings -cargo test -``` - -Then from the `ui/goose2` directory: - -```bash -source ./bin/activate-hermit -just check -just test -just tauri-check -``` - -## Summary of Deletions - -| Path | Lines | Purpose (was) | -|------|-------|---------------| -| `services/acp/manager/command_dispatch.rs` | ~258 | Command dispatch loop | -| `services/acp/manager/dispatcher.rs` | ~532 | Session event dispatcher + Client trait impl | -| `services/acp/manager/dispatcher_tests.rs` | ~28 | Dispatcher tests | -| `services/acp/manager/session_ops.rs` | ~611 | Session prepare/load/cancel/set-model | -| `services/acp/manager/session_ops/prompt_ops.rs` | ~(inline) | Send prompt logic | -| `services/acp/manager/session_ops/tests.rs` | ~(inline) | Session ops tests | -| `services/acp/manager/thread.rs` | ~169 | Manager thread + WebSocket bridge | -| `services/acp/manager.rs` | ~308 | GooseAcpManager struct + ManagerCommand enum | -| `services/acp/writer.rs` | ~156 | TauriMessageWriter | -| `services/acp/payloads.rs` | ~106 | Tauri event payload structs | -| `services/acp/registry.rs` | ~114 | AcpSessionRegistry | -| `services/acp/search.rs` | ~467 | Session content search | -| **Total** | **~2,749** | | - -Plus significant simplification of `commands/acp.rs` (~330 → ~15 lines), `services/acp/mod.rs` (~147 → ~4 lines), and `lib.rs` (~114 → ~80 lines). - -## Cargo Dependencies Removed - -| Crate | Why it was needed | -|-------|-------------------| -| `agent-client-protocol` | Rust ACP client types (Agent, ClientSideConnection, etc.) | -| `acp-client` | Agent discovery, MessageWriter trait (kept if using Option A for binary discovery) | -| `tokio-tungstenite` | WebSocket connection to goose serve | -| `async-trait` | MessageWriter + Client trait impls | -| `futures` | WebSocket stream splitting (SinkExt, StreamExt) | -| `tokio-util` | Compat adapters for async read/write | - -## Files Modified - -| File | Change | -|------|--------| -| `src-tauri/src/services/acp/mod.rs` | Simplified to just goose_serve re-export | -| `src-tauri/src/services/acp/goose_serve.rs` | Remove `WS_BRIDGE_BUFFER_BYTES` constant, replace readiness probe with TCP connect | -| `src-tauri/src/commands/acp.rs` | Replaced with single `get_goose_serve_url` command | -| `src-tauri/src/lib.rs` | Remove AcpSessionRegistry, old ACP commands, simplify run closure | -| `src-tauri/Cargo.toml` | Remove 5–6 dependencies | -| `src-tauri/Cargo.lock` | Auto-updated | - -## Files Deleted - -All files listed in the "Summary of Deletions" table above. - -## Dependencies - -- Steps 01–08 must be working and tested before this cleanup step. - -## Notes - -- Run `cargo check` after each deletion batch to catch remaining references. -- The `doctor` crate dependency stays — it's used by `commands/doctor.rs` which is not part of this migration. diff --git a/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md b/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md deleted file mode 100644 index d23ab6153e2c..000000000000 --- a/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md +++ /dev/null @@ -1,305 +0,0 @@ -# Step 10: Phase B — Migrate Config, Personas, Skills, Projects, Git, Doctor to `goose serve` - -## Objective - -Migrate each remaining Rust Tauri subsystem behind `goose serve` ACP extension methods, callable from TypeScript via `client.goose.()`. This requires backend changes to the goose crate — adding new ACP extension methods to `goose serve`. - -## Current State After Phase A (Steps 01–09) - -| Module | Rust File(s) | Lines | Native Dependency | -|--------|-------------|-------|-------------------| -| Config (config.yaml, secrets, keyring) | `services/goose_config.rs`, `services/provider_defs.rs` | ~590 | Keyring, file system | -| Credentials commands | `commands/credentials.rs` | ~50 | GooseConfig | -| Personas | `services/personas.rs`, `types/agents.rs`, `types/builtin_personas.rs` | ~920 | File system | -| Persona commands | `commands/agents.rs` | ~210 | PersonaStore | -| Skills | `commands/skills.rs` | ~320 | File system | -| Projects | `commands/projects.rs` | ~495 | File system | -| Git operations | `commands/git.rs`, `commands/git_changes.rs` | ~570 | Shell commands | -| Doctor | `commands/doctor.rs` | ~15 | `doctor` crate | -| Agent setup | `commands/agent_setup.rs` | ~310 | Shell commands, streaming output | -| Model setup | `commands/model_setup.rs` | ~220 | Shell commands, streaming output | -| System utilities | `commands/system.rs` | ~360 | File system, dialog | -| **Total** | | **~4,060** | | - -## Migration Pattern - -For each subsystem: - -1. **Backend**: Add ACP extension methods to `goose serve` (in `goose-acp` or `goose` crate) -2. **Schema**: Regenerate the ACP schema (`npm run build:schema` in `ui/acp/`) -3. **Client**: `GooseExtClient` auto-generates typed methods from the schema -4. **Frontend**: Replace `invoke("rust_command")` calls with `client.goose.()` calls -5. **Cleanup**: Delete the Rust Tauri command and service code - -## Subsystem Migration Details - -### B1: Config Management - -**Priority: High** — Config is needed for provider setup, part of the core onboarding flow. - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/config/get` | `{ key: string }` | `{ value: string \| null }` | -| `goose/config/set` | `{ key: string, value: string }` | `{}` | -| `goose/config/delete` | `{ key: string }` | `{ removed: boolean }` | -| `goose/secret/getMasked` | `{ key: string }` | `{ value: string \| null }` | -| `goose/secret/set` | `{ key: string, value: string }` | `{}` | -| `goose/secret/delete` | `{ key: string }` | `{ removed: boolean }` | -| `goose/provider/status` | `{ providerId: string }` | `{ providerId: string, isConfigured: boolean }` | -| `goose/provider/statusAll` | `{}` | `{ providers: [{ providerId: string, isConfigured: boolean }] }` | -| `goose/provider/fields` | `{ providerId: string }` | `{ fields: [{ key: string, value: string \| null, isSet: boolean, isSecret: boolean, required: boolean }] }` | -| `goose/provider/deleteConfig` | `{ providerId: string }` | `{}` | - -#### Backend Notes - -- The goose binary already has config management internally (`goose configure`). The extension methods expose the same logic over ACP. -- Keyring access happens in the `goose serve` process (which runs natively), so there is no loss of capability. -- Move `provider_defs.rs` static definitions to the goose crate. - -#### Frontend Changes - -- `invoke("get_provider_config")` → `client.goose.gooseProviderFields({ providerId })` -- `invoke("save_provider_field")` → `client.goose.gooseSecretSet({ key, value })` or `client.goose.gooseConfigSet({ key, value })` -- `invoke("delete_provider_config")` → `client.goose.gooseProviderDeleteConfig({ providerId })` -- `invoke("check_all_provider_status")` → `client.goose.gooseProviderStatusAll({})` -- `invoke("restart_app")` — remains in Rust (native window management) - -#### Files Deleted - -- `src-tauri/src/services/goose_config.rs` -- `src-tauri/src/services/provider_defs.rs` -- `src-tauri/src/commands/credentials.rs` (except `restart_app`) -- `keyring` dependency from `Cargo.toml` (all 3 platform variants) -- `etcetera` dependency - ---- - -### B2: Personas - -**Priority: Medium** — Used in the chat flow but not on the critical path. - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/personas/list` | `{}` | `{ personas: Persona[] }` | -| `goose/personas/create` | `CreatePersonaRequest` | `{ persona: Persona }` | -| `goose/personas/update` | `{ id: string, ...UpdatePersonaRequest }` | `{ persona: Persona }` | -| `goose/personas/delete` | `{ id: string }` | `{}` | -| `goose/personas/refresh` | `{}` | `{ personas: Persona[] }` | -| `goose/personas/export` | `{ id: string }` | `{ json: string, suggestedFilename: string }` | -| `goose/personas/import` | `{ fileBytes: number[], fileName: string }` | `{ personas: Persona[] }` | -| `goose/personas/saveAvatar` | `{ personaId: string, bytes: number[], extension: string }` | `{ filename: string }` | -| `goose/personas/avatarsDir` | `{}` | `{ path: string }` | - -#### Backend Notes - -- Persona storage (`~/.goose/personas.json`, `~/.goose/agents/*.md`) and avatar handling (`~/.goose/avatars/`) are file-based. The goose binary can read/write these directly. -- Move builtin persona definitions from `types/builtin_personas.rs` to the goose crate. - -#### Files Deleted - -- `src-tauri/src/services/personas.rs` -- `src-tauri/src/types/agents.rs` -- `src-tauri/src/types/builtin_personas.rs` -- `src-tauri/src/types/messages.rs` -- `src-tauri/src/types/mod.rs` -- `src-tauri/src/commands/agents.rs` - ---- - -### B3: Skills - -**Priority: Low** - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/skills/list` | `{}` | `{ skills: SkillInfo[] }` | -| `goose/skills/create` | `{ name, description, instructions }` | `{}` | -| `goose/skills/update` | `{ name, description, instructions }` | `{ skill: SkillInfo }` | -| `goose/skills/delete` | `{ name: string }` | `{}` | -| `goose/skills/export` | `{ name: string }` | `{ json: string, filename: string }` | -| `goose/skills/import` | `{ fileBytes: number[], fileName: string }` | `{ skills: SkillInfo[] }` | - -#### Files Deleted - -- `src-tauri/src/commands/skills.rs` - ---- - -### B4: Projects - -**Priority: Low** - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/projects/list` | `{}` | `{ projects: ProjectInfo[] }` | -| `goose/projects/create` | `{ name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` | -| `goose/projects/update` | `{ id, name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` | -| `goose/projects/delete` | `{ id: string }` | `{}` | -| `goose/projects/get` | `{ id: string }` | `{ project: ProjectInfo }` | -| `goose/projects/listArchived` | `{}` | `{ projects: ProjectInfo[] }` | -| `goose/projects/archive` | `{ id: string }` | `{}` | -| `goose/projects/restore` | `{ id: string }` | `{}` | - -#### Files Deleted - -- `src-tauri/src/commands/projects.rs` - ---- - -### B5: Git Operations - -**Priority: Medium** — Git state is shown in the workspace widget and context panel. - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/git/state` | `{ path: string }` | `GitState` | -| `goose/git/changedFiles` | `{ path: string }` | `{ files: ChangedFile[] }` | -| `goose/git/switchBranch` | `{ path, branch }` | `{}` | -| `goose/git/stash` | `{ path }` | `{}` | -| `goose/git/init` | `{ path }` | `{}` | -| `goose/git/fetch` | `{ path }` | `{}` | -| `goose/git/pull` | `{ path }` | `{}` | -| `goose/git/createBranch` | `{ path, name, baseBranch }` | `{}` | -| `goose/git/createWorktree` | `{ path, name, branch, createBranch, baseBranch? }` | `CreatedWorktree` | - -#### Backend Notes - -- Git operations run shell commands (`git status`, `git switch`, etc.). The goose binary runs these the same way. -- The `ignore` crate for `.gitignore`-aware file scanning in `list_files_for_mentions` moves to goose serve as well. - -#### Files Deleted - -- `src-tauri/src/commands/git.rs` -- `src-tauri/src/commands/git_changes.rs` - ---- - -### B6: Doctor - -**Priority: Low** — Diagnostic tool, not on the critical path. - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/doctor/run` | `{}` | `DoctorReport` | -| `goose/doctor/fix` | `{ checkId: string, fixType: string }` | `{}` | - -#### Backend Notes - -The `doctor` crate already exists in the goose ecosystem. The extension methods expose it over ACP. - -#### Files Deleted - -- `src-tauri/src/commands/doctor.rs` -- `doctor` dependency from `Cargo.toml` - ---- - -### B7: Agent & Model Setup - -**Priority: Medium** — Needed for onboarding third-party agents and OAuth flows. - -This subsystem involves interactive shell commands with streaming output. The current Rust code spawns a child process and streams stdout/stderr lines as Tauri events (`agent-setup:output`, `model-setup:output`). - -#### Recommendation: Keep in Rust - -These commands remain as Tauri-native commands. They are inherently interactive (opening browsers for OAuth, waiting for user input), are rarely called (only during onboarding), and migrating them would require designing a new ACP streaming notification type. They stay as the last remaining Tauri commands. - ---- - -### B8: System Utilities - -**Priority: Low** - -#### Extension Methods - -| Method | Request | Response | -|--------|---------|----------| -| `goose/system/homeDir` | `{}` | `{ path: string }` | -| `goose/system/pathExists` | `{ path: string }` | `{ exists: boolean }` | -| `goose/system/listDir` | `{ path: string }` | `{ entries: FileTreeEntry[] }` | -| `goose/system/listFilesForMentions` | `{ roots: string[], maxResults?: number }` | `{ files: string[] }` | - -#### Stays in Rust: `saveExportedSessionFile` - -This command uses `tauri_plugin_dialog` to show a native save dialog. It cannot move to `goose serve`. - -#### Files Deleted - -- `src-tauri/src/commands/system.rs` (except `save_exported_session_file`) -- `ignore` dependency from `Cargo.toml` - ---- - -## End State After Phase B - -**Rust Tauri backend (~780 lines):** - -``` -src-tauri/src/ - lib.rs — ~40 lines: spawn goose serve, register ~3 commands - main.rs — 6 lines (unchanged) - commands/ - mod.rs — 3 modules - acp.rs — get_goose_serve_url (~15 lines) - system.rs — save_exported_session_file (~40 lines) - agent_setup.rs — install/auth agents (~310 lines) - model_setup.rs — model provider auth (~220 lines) - services/ - mod.rs — 1 module - acp/ - mod.rs — 1 module - goose_serve.rs — GooseServeProcess (~150 lines) -``` - -**Cargo.toml dependencies (minimal):** - -```toml -tauri = "2" -tauri-plugin-opener = "2" -tauri-plugin-dialog = ">=2,<2.7" -tauri-plugin-window-state = "2" -tauri-plugin-log = "2" -serde = "1" -serde_json = "1" -tokio = "1" -dirs = "6" -log = "0.4" -``` - -## Migration Order - -| Step | Effort | Value | Order | -|------|--------|-------|-------| -| B1 (Config) | Medium | High (removes keyring dep) | 1st | -| B5 (Git) | Medium | Medium | 2nd | -| B2 (Personas) | Medium | Medium | 3rd | -| B3 (Skills) | Small | Small | 4th | -| B4 (Projects) | Small | Small | 5th | -| B6 (Doctor) | Small | Small | 6th | -| B8 (System utils) | Small | Small | 7th | -| B7 (Agent/Model setup) | — | — | Keep in Rust | - -All steps are blocked on implementing the corresponding backend ACP methods, except B7 which remains native. - -## Workflow Per Subsystem - -1. Design the ACP extension method schemas in `crates/goose-acp/` -2. Implement the handlers in the goose serve server -3. Regenerate the schema: `cd ui/acp && npm run build:schema` -4. Rebuild the TS client: `cd ui/acp && npm run build` -5. Update goose2: use the new `client.goose.()` calls -6. Delete the Rust Tauri code - -Each subsystem migrates independently. The frontend can use a mix of `invoke()` (not-yet-migrated) and `client.goose.*()` (migrated) during the transition. diff --git a/ui/goose2/biome.json b/ui/goose2/biome.json index 376780d276f3..891dd730a18f 100644 --- a/ui/goose2/biome.json +++ b/ui/goose2/biome.json @@ -13,7 +13,8 @@ "!src-tauri/plugins/*/permissions/{schemas,autogenerated}", "!.agents", "!.worktrees", - "!.claude/worktrees" + "!.claude/worktrees", + "!justfile" ] }, "formatter": { diff --git a/ui/goose2/justfile b/ui/goose2/justfile index f02b33a8d34a..5b868c3ed5be 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -8,8 +8,9 @@ default: # ── Dev Environment ────────────────────────────────────────── -# Install dependencies and build workspace packages +# Install dependencies, build workspace packages, and activate git hooks setup: + git config core.hooksPath .husky cd ../ && pnpm install cd ../sdk && pnpm build cargo build --manifest-path ../../Cargo.toml -p goose-cli --bin goose @@ -33,7 +34,7 @@ fmt-check: # Run clippy on Tauri backend clippy: - cd src-tauri && cargo clippy -- -D warnings + cd src-tauri && TAURI_CONFIG='{"bundle":{"externalBin":[]}}' cargo clippy -- -D warnings # Build the frontend build: @@ -45,7 +46,7 @@ tauri-fmt-check: # Check Tauri Rust types tauri-check: - cd src-tauri && cargo check + cd src-tauri && TAURI_CONFIG='{"bundle":{"externalBin":[]}}' cargo check # Full CI gate ci: check clippy test build tauri-check @@ -78,7 +79,7 @@ test-e2e-all: # ── Run ────────────────────────────────────────────────────── # Start the desktop app in dev mode -dev: +dev: setup #!/usr/bin/env bash set -euo pipefail @@ -88,10 +89,24 @@ dev: # Override with e.g. RUST_LOG=info just dev to disable. export RUST_LOG="${RUST_LOG:-perf=debug,info}" PROJECT_DIR=$(pwd) - GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose" - export GOOSE_BIN + REPO_ROOT=$(cd ../.. && pwd) + LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" + LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" + if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}" + elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}" + else + unset GOOSE_BIN + fi EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"cd ${PROJECT_DIR} && exec pnpm exec vite --port ${VITE_PORT} --strictPort\",\"cwd\":\".\",\"wait\":false}}}") + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using local goose binary: ${GOOSE_BIN}" + else + echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) @@ -113,18 +128,33 @@ dev: pnpm tauri dev --features app-test-driver --config src-tauri/tauri.dev.conf.json "${EXTRA_CONFIG_ARGS[@]}" # Start the desktop app with dev config -dev-debug: +dev-debug: setup #!/usr/bin/env bash set -euo pipefail + VITE_PORT={{ vite_port }} + export VITE_PORT # Enable perf logs in the child `goose serve` process by default. # Override with e.g. RUST_LOG=info just dev-debug to disable. export RUST_LOG="${RUST_LOG:-perf=debug,info}" - PROJECT_DIR=$(pwd) - GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose" - export GOOSE_BIN + REPO_ROOT=$(cd ../.. && pwd) + LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" + LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" + if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}" + elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}" + else + unset GOOSE_BIN + fi EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort\",\"cwd\":\"..\",\"wait\":false}}}") + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using local goose binary: ${GOOSE_BIN}" + else + echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) @@ -149,12 +179,45 @@ dev-debug: dev-frontend: pnpm dev -# Kill the dev process (if running) -kill: +# Parse `git worktree list --porcelain` into tab-separated "path\tbranch" lines. +# Uses sub() instead of $2 so worktree paths containing spaces are preserved. +# Detached-HEAD worktrees (no branch line) are silently skipped. +_worktree_awk := '/^worktree / { sub(/^worktree /, ""); wt=$0 } /^branch refs\/heads\// { b=$2; sub(/^refs\/heads\//, "", b); print wt "\t" b }' + +# Compute a stable vite port from a directory path passed as $1. +_port_cmd := "import hashlib,sys; h=int(hashlib.sha256(sys.argv[1].encode()).hexdigest(),16); print(10000+h%55000)" + +# List worktrees with an active dev server +running: + #!/usr/bin/env bash + git worktree list --porcelain | awk '{{ _worktree_awk }}' \ + | while IFS=$'\t' read -r wt branch; do + dir="$wt/ui/goose2" + port=$(python3 -c '{{ _port_cmd }}' "$dir") + if lsof -ti :"$port" &>/dev/null; then + echo "$branch" + fi + done + +# Kill the dev process (if running). Optionally pass a branch name to kill another worktree's process. +kill branch="": #!/usr/bin/env bash set -euo pipefail - VITE_PORT={{ vite_port }} + if [[ -n "{{ branch }}" ]]; then + WORKTREE_PATH=$(git worktree list --porcelain \ + | awk '{{ _worktree_awk }}' \ + | awk -F'\t' -v branch="{{ branch }}" '$2 == branch { print $1; exit }') + if [[ -z "$WORKTREE_PATH" ]]; then + echo "No worktree found for branch '{{ branch }}'" + exit 1 + fi + TARGET_DIR="$WORKTREE_PATH/ui/goose2" + VITE_PORT=$(python3 -c '{{ _port_cmd }}' "$TARGET_DIR") + else + VITE_PORT={{ vite_port }} + fi + PID=$(lsof -ti :"$VITE_PORT" 2>/dev/null | head -1) || true if [[ -z "$PID" ]]; then @@ -163,13 +226,33 @@ kill: fi PROC_NAME=$(ps -p "$PID" -o comm= 2>/dev/null) || true - if [[ "$PROC_NAME" != "node" ]]; then + PROC_BASENAME="${PROC_NAME##*/}" + if [[ "$PROC_BASENAME" != "node" ]]; then echo "Process on port $VITE_PORT is '$PROC_NAME', not 'node' — refusing to kill" exit 1 fi - echo "Killing node (PID $PID) on port $VITE_PORT" - kill -9 "$PID" + PGID=$(ps -p "$PID" -o pgid= 2>/dev/null | tr -d ' ') + if [[ -z "$PGID" || "$PGID" == "0" || "$PGID" == "1" ]]; then + echo "Killing node (PID $PID) on port $VITE_PORT" + kill -9 "$PID" + else + echo "Killing process group $PGID (found via node PID $PID on port $VITE_PORT)" + kill -9 -"$PGID" 2>/dev/null || true + fi + +# Kill all dev processes across all worktrees +kill-all: + #!/usr/bin/env bash + set -euo pipefail + branches=$(just running) + if [[ -z "$branches" ]]; then + echo "No running dev servers found" + exit 0 + fi + while read -r branch; do + just kill "$branch" || true + done <<< "$branches" # ── Utilities ──────────────────────────────────────────────── diff --git a/ui/goose2/package.json b/ui/goose2/package.json index b61fea02ddb6..a1c36adc5648 100644 --- a/ui/goose2/package.json +++ b/ui/goose2/package.json @@ -1,7 +1,7 @@ { "name": "goose2", "private": true, - "version": "0.1.0", + "version": "0.19.0", "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 5d2066a196d0..1cda1e566459 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -13,7 +13,17 @@ const EXCEPTIONS = { "src/features/chat/ui/ChatView.tsx": { limit: 570, justification: - "ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here. Includes gated [perf:chatview] logging via perfLog (dev-only by default).", + "ACP prewarm guards, project-aware working dir selection, working context sync, chat bootstrapping, context-ring compaction wiring, and gated [perf:chatview] logging via perfLog (dev-only by default).", + }, + "src/features/chat/hooks/useChat.ts": { + limit: 510, + justification: + "Session preparation, provider/model handoff, persona-aware sends, cancellation, and compaction replay still live in one chat lifecycle hook.", + }, + "src/shared/api/acpNotificationHandler.ts": { + limit: 550, + justification: + "ACP replay/live update handling, pending session buffering, model/config propagation, and streaming perf tracking still share one notification entrypoint.", }, "src/features/chat/ui/__tests__/ContextPanel.test.tsx": { limit: 550, @@ -26,9 +36,29 @@ const EXCEPTIONS = { "Search-as-you-type filtering and draft-aware sidebar highlight logic.", }, "src/app/AppShell.tsx": { - limit: 660, + limit: 780, + justification: + "Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, restored project-draft reuse, and app-level compaction settings deep links. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).", + }, + "src/features/chat/hooks/useChatSessionController.ts": { + limit: 840, + justification: + "Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, context-usage readiness resets, queued-target compaction gating, and auto-compaction-aware send orchestration pending a later decomposition pass.", + }, + "src/features/chat/hooks/__tests__/useChatSessionController.test.ts": { + limit: 520, + justification: + "Controller regression coverage now spans model/provider rollback, stale usage resets, compact-before-send, and queued-persona auto-compaction support checks in one hook suite.", + }, + "src/features/chat/stores/chatStore.ts": { + limit: 520, + justification: + "Chat runtime state, queued-message persistence, replay loading flags, and usage snapshot tracking still live together in one Zustand store.", + }, + "src/features/chat/ui/AgentModelPicker.tsx": { + limit: 570, justification: - "Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, and app-level chat routing. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).", + "Agent-first picker currently keeps the full trigger, recommended-model view, searchable full-model view, and ACP/goose-specific labeling logic in one component pending later extraction.", }, "src/features/chat/stores/__tests__/chatSessionStore.test.ts": { limit: 540, @@ -40,6 +70,21 @@ const EXCEPTIONS = { justification: "ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.", }, + "src/features/chat/ui/ChatInput.tsx": { + limit: 510, + justification: + "Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.", + }, + "src/features/chat/ui/MessageBubble.tsx": { + limit: 520, + justification: + "Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.", + }, + "src/features/chat/ui/__tests__/ChatInput.test.tsx": { + limit: 570, + justification: + "Composer regression coverage spans personas, queueing, attachments, voice-input edge cases, and the compaction popover/settings ingress in one interaction-heavy suite.", + }, "src-tauri/src/commands/projects.rs": { limit: 520, justification: diff --git a/ui/goose2/scripts/reset-inventory.sh b/ui/goose2/scripts/reset-inventory.sh new file mode 100755 index 000000000000..73125f683102 --- /dev/null +++ b/ui/goose2/scripts/reset-inventory.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Reset the provider inventory tables to empty, as if migration 12 just ran. +# This lets you test the first-use experience (cold inventory). +# +# Usage: ./scripts/reset-inventory.sh + +set -euo pipefail + +DB="${GOOSE_DB:-$HOME/.local/share/goose/sessions/sessions.db}" + +if [ ! -f "$DB" ]; then + echo "Database not found at $DB" + echo "Set GOOSE_DB to override the path." + exit 1 +fi + +echo "Database: $DB" +echo "" +echo "Before:" +echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')" +echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')" + +# ON DELETE CASCADE on provider_inventory_models means deleting entries clears both tables. +# Delete models first since CASCADE isn't reliable in all sqlite3 builds, +# then delete entries. +sqlite3 "$DB" "DELETE FROM provider_inventory_models; DELETE FROM provider_inventory_entries;" + +echo "" +echo "After:" +echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')" +echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')" +echo "" +echo "Inventory tables are empty. Restart goose to test first-use flow." diff --git a/ui/goose2/src-tauri/Info.plist b/ui/goose2/src-tauri/Info.plist new file mode 100644 index 000000000000..8588d2d741c4 --- /dev/null +++ b/ui/goose2/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + Goose uses your microphone to capture voice input for dictation. + + diff --git a/ui/goose2/src-tauri/entitlements.plist b/ui/goose2/src-tauri/entitlements.plist new file mode 100644 index 000000000000..a5f008eaaadc --- /dev/null +++ b/ui/goose2/src-tauri/entitlements.plist @@ -0,0 +1,22 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.device.audio-input + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/ui/goose2/src-tauri/icons/icon.ico b/ui/goose2/src-tauri/icons/icon.ico index db5b7751d29f..83c727d9a309 100644 Binary files a/ui/goose2/src-tauri/icons/icon.ico and b/ui/goose2/src-tauri/icons/icon.ico differ diff --git a/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs b/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs index 256b2c29e1f6..0d7c09998b63 100644 --- a/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs +++ b/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::sync::Mutex; -use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; +use tauri::{AppHandle, Manager, Runtime}; +#[cfg(target_os = "macos")] +use tauri::WebviewWindow; #[derive(Deserialize, Debug)] struct TestCommand { diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index 9d97d0c6d69a..2906da85b6bf 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -1,7 +1,14 @@ +use std::env; + use crate::services::acp::GooseServeProcess; #[tauri::command] pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result { + if let Ok(url) = env::var("GOOSE_SERVE_URL") { + if !url.is_empty() { + return Ok(url); + } + } let process = GooseServeProcess::get(app_handle).await?; Ok(process.ws_url()) } diff --git a/ui/goose2/src-tauri/src/commands/agent_setup.rs b/ui/goose2/src-tauri/src/commands/agent_setup.rs index 7867e9cce2b9..58386d2ee93f 100644 --- a/ui/goose2/src-tauri/src/commands/agent_setup.rs +++ b/ui/goose2/src-tauri/src/commands/agent_setup.rs @@ -15,7 +15,7 @@ const AGENT_COMMAND_DEFS: &[AgentCommandDef] = &[ id: "claude-acp", binary_name: "claude-agent-acp", install_command: Some( - "npm install -g @anthropic-ai/claude-code @zed-industries/claude-agent-acp", + "npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp", ), auth_command: Some("claude auth login"), auth_status_command: Some("claude auth status"), diff --git a/ui/goose2/src-tauri/src/commands/agents.rs b/ui/goose2/src-tauri/src/commands/agents.rs index a83499f20507..3d3564c4cb54 100644 --- a/ui/goose2/src-tauri/src/commands/agents.rs +++ b/ui/goose2/src-tauri/src/commands/agents.rs @@ -1,6 +1,7 @@ use crate::services::personas::PersonaStore; use crate::types::agents::*; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use tauri::State; #[tauri::command] @@ -59,6 +60,57 @@ pub fn get_avatars_dir() -> String { PersonaStore::avatars_dir().to_string_lossy().to_string() } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportFileReadResult { + pub file_bytes: Vec, + pub file_name: String, +} + +fn validate_import_persona_path(source_path: &str) -> Result { + let path = PathBuf::from(source_path); + + if path.as_os_str().is_empty() { + return Err("Selected file path is empty".to_string()); + } + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| "Unsupported file type. Expected a .json file.".to_string())?; + if !extension.eq_ignore_ascii_case("json") { + return Err("Unsupported file type. Expected a .json file.".to_string()); + } + + let metadata = std::fs::metadata(&path) + .map_err(|err| format!("Failed to access import file '{}': {}", path.display(), err))?; + if !metadata.is_file() { + return Err(format!( + "Selected import path '{}' is not a file", + path.display() + )); + } + + Ok(path) +} + +#[tauri::command] +pub fn read_import_persona_file(source_path: String) -> Result { + let path = validate_import_persona_path(&source_path)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Selected file is missing a valid filename".to_string())? + .to_string(); + let file_bytes = std::fs::read(&path) + .map_err(|err| format!("Failed to read import file '{}': {}", path.display(), err))?; + + Ok(ImportFileReadResult { + file_bytes, + file_name, + }) +} + // --- Sprout-compatible persona import/export --- /// Sprout-compatible persona export format (version 1, camelCase keys). @@ -208,3 +260,41 @@ pub fn import_personas( let persona = store.create(request)?; Ok(vec![persona]) } + +#[cfg(test)] +mod tests { + use super::validate_import_persona_path; + + #[test] + fn validate_import_persona_path_rejects_non_json_files() { + let path = std::env::temp_dir().join("persona-import.txt"); + std::fs::write(&path, b"{}").unwrap(); + + let result = validate_import_persona_path(path.to_str().unwrap()); + + assert!(result.is_err()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn validate_import_persona_path_rejects_directories() { + let dir = std::env::temp_dir().join(format!("persona-import-dir-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let result = validate_import_persona_path(dir.to_str().unwrap()); + + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn validate_import_persona_path_accepts_json_files() { + let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id())); + std::fs::write(&path, b"{}").unwrap(); + + let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap(); + + assert_eq!(validated, path); + let _ = std::fs::remove_file(validated); + } +} diff --git a/ui/goose2/src-tauri/src/commands/extensions.rs b/ui/goose2/src-tauri/src/commands/extensions.rs deleted file mode 100644 index 5b4040f836b5..000000000000 --- a/ui/goose2/src-tauri/src/commands/extensions.rs +++ /dev/null @@ -1,168 +0,0 @@ -use serde_json::Value; -use tauri::State; - -use crate::services::goose_config::GooseConfig; - -fn yaml_to_json(yaml: serde_yaml::Value) -> Value { - match yaml { - serde_yaml::Value::Null => Value::Null, - serde_yaml::Value::Bool(b) => Value::Bool(b), - serde_yaml::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Number(i.into()) - } else if let Some(u) = n.as_u64() { - Value::Number(u.into()) - } else if let Some(f) = n.as_f64() { - serde_json::Number::from_f64(f) - .map(Value::Number) - .unwrap_or(Value::Null) - } else { - Value::Null - } - } - serde_yaml::Value::String(s) => Value::String(s), - serde_yaml::Value::Sequence(seq) => { - Value::Array(seq.into_iter().map(yaml_to_json).collect()) - } - serde_yaml::Value::Mapping(map) => { - let obj = map - .into_iter() - .filter_map(|(k, v)| { - let key = match k { - serde_yaml::Value::String(s) => s, - other => serde_yaml::to_string(&other).ok()?.trim().to_string(), - }; - Some((key, yaml_to_json(v))) - }) - .collect(); - Value::Object(obj) - } - serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), - } -} - -fn json_to_yaml(json: Value) -> serde_yaml::Value { - match json { - Value::Null => serde_yaml::Value::Null, - Value::Bool(b) => serde_yaml::Value::Bool(b), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - serde_yaml::Value::Number(i.into()) - } else if let Some(u) = n.as_u64() { - serde_yaml::Value::Number(u.into()) - } else if let Some(f) = n.as_f64() { - serde_yaml::Value::Number(f.into()) - } else { - serde_yaml::Value::Null - } - } - Value::String(s) => serde_yaml::Value::String(s), - Value::Array(arr) => { - serde_yaml::Value::Sequence(arr.into_iter().map(json_to_yaml).collect()) - } - Value::Object(obj) => { - let mut map = serde_yaml::Mapping::new(); - for (k, v) in obj { - map.insert(serde_yaml::Value::String(k), json_to_yaml(v)); - } - serde_yaml::Value::Mapping(map) - } - } -} - -fn name_to_key(name: &str) -> String { - let mut result = String::with_capacity(name.len()); - for c in name.chars() { - match c { - c if c.is_ascii_alphanumeric() || c == '_' || c == '-' => result.push(c), - c if c.is_whitespace() => continue, - _ => result.push('_'), - } - } - result.to_lowercase() -} - -#[tauri::command] -pub fn list_extensions(config: State<'_, GooseConfig>) -> Result, String> { - let raw = config.get_extensions_raw(); - let mut entries = Vec::with_capacity(raw.len()); - - for (k, v) in raw { - let key = match k { - serde_yaml::Value::String(s) => s, - _ => continue, - }; - - let mut json = yaml_to_json(v); - - if let Value::Object(ref mut obj) = json { - if !obj.contains_key("type") { - continue; - } - obj.insert("config_key".to_string(), Value::String(key.clone())); - obj.entry("name".to_string()) - .or_insert_with(|| Value::String(key)); - obj.entry("enabled".to_string()) - .or_insert(Value::Bool(false)); - entries.push(json); - } - } - - Ok(entries) -} - -#[tauri::command] -pub fn add_extension( - name: String, - extension_config: Value, - enabled: bool, - config: State<'_, GooseConfig>, -) -> Result<(), String> { - let key = name_to_key(&name); - let mut raw = config.get_extensions_raw(); - - let mut entry = match extension_config { - Value::Object(obj) => obj, - _ => return Err("extension_config must be a JSON object".to_string()), - }; - - entry.insert("enabled".to_string(), Value::Bool(enabled)); - entry.insert("name".to_string(), Value::String(name)); - - let yaml_value = json_to_yaml(Value::Object(entry)); - raw.insert(serde_yaml::Value::String(key), yaml_value); - - config.set_extensions_raw(raw) -} - -#[tauri::command] -pub fn remove_extension(config_key: String, config: State<'_, GooseConfig>) -> Result<(), String> { - let mut raw = config.get_extensions_raw(); - let yaml_key = serde_yaml::Value::String(config_key.clone()); - if raw.remove(&yaml_key).is_none() { - return Err(format!("Extension '{}' not found", config_key)); - } - config.set_extensions_raw(raw) -} - -#[tauri::command] -pub fn toggle_extension( - config_key: String, - enabled: bool, - config: State<'_, GooseConfig>, -) -> Result<(), String> { - let mut raw = config.get_extensions_raw(); - - let yaml_key = serde_yaml::Value::String(config_key.clone()); - if let Some(entry) = raw.get_mut(&yaml_key) { - if let serde_yaml::Value::Mapping(ref mut map) = entry { - map.insert( - serde_yaml::Value::String("enabled".to_string()), - serde_yaml::Value::Bool(enabled), - ); - } - config.set_extensions_raw(raw) - } else { - Err(format!("Extension '{}' not found", config_key)) - } -} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 7401328c9684..51e933f64256 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -3,11 +3,9 @@ pub mod agent_setup; pub mod agents; pub mod credentials; pub mod doctor; -pub mod extensions; pub mod git; pub mod git_changes; pub mod model_setup; pub mod path_resolver; pub mod projects; -pub mod skills; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/skills.rs b/ui/goose2/src-tauri/src/commands/skills.rs deleted file mode 100644 index 4d975da45328..000000000000 --- a/ui/goose2/src-tauri/src/commands/skills.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -fn skills_dir() -> Result { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - Ok(home.join(".agents").join("skills")) -} - -/// Validates that a skill name is kebab-case only: `^[a-z0-9]+(-[a-z0-9]+)*$`. -/// This prevents path traversal attacks (e.g. `../../.ssh/authorized_keys`). -fn validate_skill_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - let mut expect_alnum = true; // true = next char must be [a-z0-9], false = can also be '-' - for ch in name.chars() { - if ch.is_ascii_lowercase() || ch.is_ascii_digit() { - expect_alnum = false; - } else if ch == '-' && !expect_alnum { - expect_alnum = true; // char after '-' must be [a-z0-9] - } else { - return Err(format!( - "Invalid skill name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \ - must not start or end with a hyphen or contain consecutive hyphens).", - name - )); - } - } - if expect_alnum { - // name ended with '-' - return Err(format!( - "Invalid skill name \"{}\". Names must not end with a hyphen.", - name - )); - } - Ok(()) -} - -fn build_skill_md(name: &str, description: &str, instructions: &str) -> String { - // Escape embedded single quotes by doubling them, then wrap in single quotes - // to prevent YAML injection in the description field. - let safe_desc = description.replace('\'', "''"); - let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); - if !instructions.is_empty() { - md.push('\n'); - md.push_str(instructions); - md.push('\n'); - } - md -} - -#[tauri::command] -pub fn create_skill(name: String, description: String, instructions: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if dir.exists() { - return Err(format!("A skill named \"{}\" already exists", name)); - } - - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(()) -} - -#[tauri::command] -pub fn list_skills() -> Result, String> { - let dir = skills_dir()?; - - if !dir.exists() { - return Ok(vec![]); - } - - let mut skills = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read skills dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let skill_md = path.join("SKILL.md"); - if !skill_md.exists() { - continue; - } - - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let raw = fs::read_to_string(&skill_md).unwrap_or_default(); - let (description, instructions) = parse_frontmatter(&raw); - - skills.push(SkillInfo { - name, - description, - instructions, - path: skill_md.to_string_lossy().to_string(), - }); - } - - skills.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(skills) -} - -#[tauri::command] -pub fn delete_skill(name: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete skill: {}", e))?; - Ok(()) -} - -fn parse_frontmatter(raw: &str) -> (String, String) { - let trimmed = raw.trim(); - if !trimmed.starts_with("---") { - return (String::new(), raw.to_string()); - } - - if let Some(end) = trimmed[3..].find("\n---") { - let front = &trimmed[3..3 + end].trim(); - let body = trimmed[3 + end + 4..].trim().to_string(); - - let mut description = String::new(); - for line in front.lines() { - let line = line.trim(); - if let Some(rest) = line.strip_prefix("description:") { - let val = rest.trim(); - // Strip surrounding quotes (single or double) - let unquoted = val - .trim_start_matches(['\'', '"']) - .trim_end_matches(['\'', '"']); - description = if val.starts_with('\'') { - // Un-escape doubled single quotes - unquoted.replace("''", "'") - } else { - // Legacy double-quote format - unquoted.replace("\\\"", "\"") - } - .to_string(); - } - } - - (description, body) - } else { - (String::new(), raw.to_string()) - } -} - -#[derive(serde::Serialize, Clone)] -pub struct SkillInfo { - pub name: String, - pub description: String, - pub instructions: String, - pub path: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillExportV1 { - version: u32, - name: String, - description: String, - #[serde(skip_serializing_if = "String::is_empty")] - instructions: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportSkillResult { - json: String, - filename: String, -} - -#[tauri::command] -pub fn update_skill( - name: String, - description: String, - instructions: String, -) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(SkillInfo { - name: name.clone(), - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }) -} - -#[tauri::command] -pub fn export_skill(name: String) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_md = dir.join("SKILL.md"); - let raw = - fs::read_to_string(&skill_md).map_err(|e| format!("Failed to read SKILL.md: {}", e))?; - let (description, instructions) = parse_frontmatter(&raw); - - let export = SkillExportV1 { - version: 1, - name: name.clone(), - description, - instructions, - }; - - let json = serde_json::to_string_pretty(&export) - .map_err(|e| format!("Failed to serialize skill: {}", e))?; - - let filename = format!("{}.skill.json", name); - - Ok(ExportSkillResult { json, filename }) -} - -#[tauri::command] -pub fn import_skills(file_bytes: Vec, file_name: String) -> Result, String> { - // Validate file extension - if !file_name.ends_with(".skill.json") && !file_name.ends_with(".json") { - return Err("File must have a .skill.json or .json extension".to_string()); - } - - // Parse bytes as UTF-8 - let text = - String::from_utf8(file_bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))?; - - // Parse as JSON - let value: serde_json::Value = - serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?; - - // Validate version - let version = value - .get("version") - .and_then(|v| v.as_u64()) - .ok_or("Missing or invalid \"version\" field")?; - if version != 1 { - return Err(format!("Unsupported skill export version: {}", version)); - } - - // Extract fields - let name = value - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"name\" field")? - .to_string(); - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - - let description = value - .get("description") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"description\" field")? - .to_string(); - if description.is_empty() { - return Err("Skill description must not be empty".to_string()); - } - - let instructions = value - .get("instructions") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // Validate the name - validate_skill_name(&name)?; - - // Determine final name, avoiding collisions - let base_dir = skills_dir()?; - let mut final_name = name.clone(); - if base_dir.join(&final_name).exists() { - final_name = format!("{}-imported", name); - // If that also exists, append a number - let mut counter = 2u32; - while base_dir.join(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; - } - } - - // Create the skill on disk - let dir = base_dir.join(&final_name); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&final_name, &description, &instructions); - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(vec![SkillInfo { - name: final_name, - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }]) -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index ff900e017038..e82d11fa0d97 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -40,16 +40,11 @@ pub fn run() { commands::agents::refresh_personas, commands::agents::export_persona, commands::agents::import_personas, + commands::agents::read_import_persona_file, commands::agents::save_persona_avatar, commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, - commands::skills::create_skill, - commands::skills::list_skills, - commands::skills::delete_skill, - commands::skills::update_skill, - commands::skills::export_skill, - commands::skills::import_skills, commands::projects::list_projects, commands::projects::create_project, commands::projects::update_project, @@ -61,10 +56,6 @@ pub fn run() { commands::projects::restore_project, commands::doctor::run_doctor, commands::doctor::run_doctor_fix, - commands::extensions::list_extensions, - commands::extensions::add_extension, - commands::extensions::remove_extension, - commands::extensions::toggle_extension, commands::git::get_git_state, commands::git_changes::get_changed_files, commands::git::git_switch_branch, diff --git a/ui/goose2/src-tauri/src/services/goose_config.rs b/ui/goose2/src-tauri/src/services/goose_config.rs index b29319b89a08..b77b154d34ef 100644 --- a/ui/goose2/src-tauri/src/services/goose_config.rs +++ b/ui/goose2/src-tauri/src/services/goose_config.rs @@ -321,26 +321,6 @@ impl GooseConfig { .collect()) } - pub fn get_extensions_raw(&self) -> serde_yaml::Mapping { - let config = self.read_config_map(); - let key = serde_yaml::Value::String("extensions".to_string()); - config - .get(&key) - .and_then(|v| v.as_mapping()) - .cloned() - .unwrap_or_default() - } - - pub fn set_extensions_raw(&self, extensions: serde_yaml::Mapping) -> Result<(), String> { - let _guard = self.guard.lock().unwrap(); - let mut config = self.read_config_map(); - config.insert( - serde_yaml::Value::String("extensions".to_string()), - serde_yaml::Value::Mapping(extensions), - ); - self.write_config_map(&config) - } - pub fn delete_all_provider_fields(&self, provider_id: &str) -> Result<(), String> { let def = find_provider_def(provider_id) .ok_or_else(|| format!("Unknown provider '{provider_id}'"))?; diff --git a/ui/goose2/src-tauri/src/services/personas.rs b/ui/goose2/src-tauri/src/services/personas.rs index 8a3f1c4c7244..5a2f0fb4874e 100644 --- a/ui/goose2/src-tauri/src/services/personas.rs +++ b/ui/goose2/src-tauri/src/services/personas.rs @@ -3,7 +3,7 @@ use crate::types::agents::{ }; use log::warn; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; pub struct PersonaStore { @@ -197,6 +197,26 @@ impl PersonaStore { }) } + fn markdown_persona_path(id: &str) -> Result { + let slug = id + .strip_prefix("md-") + .ok_or_else(|| format!("Persona '{}' is not a file-backed persona", id))?; + Self::validate_markdown_persona_slug(slug)?; + Ok(Self::agents_dir().join(format!("{}.md", slug))) + } + + fn validate_markdown_persona_slug(slug: &str) -> Result<(), String> { + if slug.chars().any(|c| matches!(c, '/' | '\\')) { + return Err(format!("Persona '{}' has an invalid file-backed ID", slug)); + } + + let mut components = Path::new(slug).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(()), + _ => Err(format!("Persona '{}' has an invalid file-backed ID", slug)), + } + } + /// Re-scan markdown personas and update the in-memory list. /// Returns the full updated persona list. pub fn refresh_markdown(&self) -> Vec { @@ -298,13 +318,29 @@ impl PersonaStore { let persona = personas .iter() .find(|p| p.id == id) + .cloned() .ok_or_else(|| format!("Persona '{}' not found", id))?; if persona.is_builtin { return Err("Cannot delete a built-in persona".to_string()); } if persona.is_from_disk { - return Err("Cannot delete a markdown persona — delete the file directly".to_string()); + let path = Self::markdown_persona_path(id)?; + match std::fs::remove_file(&path) { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "Failed to delete file-backed persona '{}': {}", + path.display(), + err + )); + } + } + + personas.retain(|p| p.id != id); + self.save_to_disk(&personas); + return Ok(()); } // Clean up local avatar file if present @@ -395,3 +431,27 @@ impl PersonaStore { let _ = std::fs::remove_file(path); } } + +#[cfg(test)] +mod tests { + use super::PersonaStore; + + #[test] + fn markdown_persona_path_rejects_parent_segments() { + assert!(PersonaStore::markdown_persona_path("md-../secret").is_err()); + assert!(PersonaStore::markdown_persona_path("md-..").is_err()); + } + + #[test] + fn markdown_persona_path_rejects_path_separators() { + assert!(PersonaStore::markdown_persona_path("md-nested/slug").is_err()); + assert!(PersonaStore::markdown_persona_path(r"md-nested\slug").is_err()); + } + + #[test] + fn markdown_persona_path_accepts_normal_slug() { + let path = PersonaStore::markdown_persona_path("md-scout").unwrap(); + let file_name = path.file_name().and_then(|name| name.to_str()); + assert_eq!(file_name, Some("scout.md")); + } +} diff --git a/ui/goose2/src-tauri/src/services/provider_defs.rs b/ui/goose2/src-tauri/src/services/provider_defs.rs index 0a2a326eaf00..5eea0c0a5a64 100644 --- a/ui/goose2/src-tauri/src/services/provider_defs.rs +++ b/ui/goose2/src-tauri/src/services/provider_defs.rs @@ -125,6 +125,17 @@ pub(crate) static PROVIDER_CONFIG_DEFS: &[ProviderConfigDef] = &[ keys: &[], oauth_cache_path: None, }, + // Dictation providers (voice input) + ProviderConfigDef { + id: "dictation_groq", + keys: &[key("GROQ_API_KEY", true, true)], + oauth_cache_path: None, + }, + ProviderConfigDef { + id: "dictation_elevenlabs", + keys: &[key("ELEVENLABS_API_KEY", true, true)], + oauth_cache_path: None, + }, ]; pub(crate) fn find_config_key(key_name: &str) -> Option<&'static ConfigKey> { diff --git a/ui/goose2/src-tauri/tauri.conf.json b/ui/goose2/src-tauri/tauri.conf.json index 11f0ef4aefb0..8b555dad1ac8 100644 --- a/ui/goose2/src-tauri/tauri.conf.json +++ b/ui/goose2/src-tauri/tauri.conf.json @@ -44,6 +44,10 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": ["../../../target/release/goose"] + "externalBin": ["../../../target/release/goose"], + "macOS": { + "entitlements": "entitlements.plist", + "hardenedRuntime": true + } } } diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index a38dde0b17b2..0b39464d4db9 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,28 +1,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import type { SectionId } from "@/features/settings/ui/SettingsModal"; +import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; import { TopBar } from "./ui/TopBar"; import { useChatStore } from "@/features/chat/stores/chatStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findExistingDraft } from "@/features/chat/lib/newChat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; +import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; +import { loadStoredHomeSessionId } from "./lib/homeSessionStorage"; +import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference"; +import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation"; import { AppShellContent } from "./ui/AppShellContent"; -import { acpPrepareSession } from "@/shared/api/acp"; +import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; import { clearReplayBuffer, getAndDeleteReplayBuffer, } from "@/features/chat/hooks/replayBuffer"; import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { perfLog } from "@/shared/lib/perfLog"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer"; export type AppView = | "home" @@ -37,7 +46,18 @@ const SIDEBAR_MIN_WIDTH = 180; const SIDEBAR_MAX_WIDTH = 380; const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100; const SIDEBAR_COLLAPSED_WIDTH = 48; - +const SETTINGS_SECTIONS = new Set([ + "appearance", + "providers", + "compaction", + "extensions", + "voice", + "general", + "projects", + "chats", + "doctor", + "about", +]); export function AppShell({ children }: { children?: React.ReactNode }) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH); @@ -52,19 +72,21 @@ export function AppShell({ children }: { children?: React.ReactNode }) { null, ); const [activeView, setActiveView] = useState("home"); - const [homeSelectedProvider, setHomeSelectedProvider] = useState< - string | undefined - >(); + const [homeSessionId, setHomeSessionId] = useState(() => + loadStoredHomeSessionId(), + ); const chatStore = useChatStore(); const sessionStore = useChatSessionStore(); const agentStore = useAgentStore(); const projectStore = useProjectStore(); - + const providerInventoryEntries = useProviderInventoryStore((s) => s.entries); const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); - + const homeSessionRequestRef = useRef | null>( + null, + ); const loadSessionMessages = useCallback(async (sessionId: string) => { const sid = sessionId.slice(0, 8); const existingMsgs = useChatStore.getState().messagesBySession[sessionId]; @@ -95,14 +117,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const tFlush = performance.now(); useChatStore.getState().setSessionLoading(sessionId, false); const buffer = getAndDeleteReplayBuffer(sessionId); + const replayMessages = buffer + ? sanitizeReplayMessages(buffer) + : undefined; const replayStats = getReplayPerf(sessionId); clearReplayPerf(sessionId); - if (buffer && buffer.length > 0) { - useChatStore.getState().setMessages(sessionId, buffer); + if (replayMessages) { + useChatStore.getState().setMessages(sessionId, replayMessages); } const t2 = performance.now(); perfLog( - `[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`, + `[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${replayMessages?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`, ); } catch (err) { console.error("Failed to load session messages:", err); @@ -125,70 +150,155 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } }, [activeSessionId, activeView]); - const isHome = activeSessionId === null && activeView === "home"; + const isHome = activeView === "home"; const activeSession = activeSessionId ? sessionStore.getSession(activeSessionId) : undefined; - const modelName = activeSession?.modelName; - const tokenCount = activeSessionId - ? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens - : 0; - - const [pendingInitialMessage, setPendingInitialMessage] = useState< - string | undefined - >(); - const [pendingInitialAttachments, setPendingInitialAttachments] = useState< - ChatAttachmentDraft[] | undefined - >(); - const [homeSelectedPersonaId, setHomeSelectedPersonaId] = useState< - string | undefined - >(); - - const cleanupEmptyDraft = useCallback( - (sessionId: string | null) => { - if (!sessionId) return; - const state = useChatSessionStore.getState(); - const session = state.sessions.find((s) => s.id === sessionId); - if (!session?.draft) return; - const draft = useChatStore.getState().draftsBySession[sessionId] ?? ""; - if (draft.length > 0) return; // has typed text — keep it - chatStore.cleanupSession(sessionId); - state.removeDraft(sessionId); - }, - [chatStore], - ); + const modelName = + activeView === "chat" ? activeSession?.modelName : undefined; + const tokenCount = + activeView === "chat" && activeSessionId + ? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens + : 0; + const homeSession = homeSessionId + ? sessionStore.getSession(homeSessionId) + : undefined; + + useHomeSessionStateSync({ + homeSessionId, + homeSession, + messagesBySession: chatStore.messagesBySession, + hasHydratedSessions: sessionStore.hasHydratedSessions, + isLoading: sessionStore.isLoading, + setHomeSessionId, + }); + + const ensureHomeSession = useCallback(async () => { + if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) { + return null; + } + + if (homeSessionRequestRef.current) { + return homeSessionRequestRef.current; + } + + const request = (async () => { + if ( + homeSession && + !homeSession.archivedAt && + homeSession.messageCount === 0 + ) { + const sessionModelPreference = + await resolveSupportedSessionModelPreference( + agentStore.selectedProvider ?? "goose", + providerInventoryEntries, + ); + const project = homeSession.projectId + ? (projectStore.projects.find( + (candidate) => candidate.id === homeSession.projectId, + ) ?? null) + : null; + const workingDir = await resolveSessionCwd(project); + await acpPrepareSession( + homeSession.id, + sessionModelPreference.providerId, + workingDir, + { + personaId: homeSession.personaId, + }, + ); + const shouldClearHomeModel = + sessionModelPreference.providerId !== homeSession.providerId || + !sessionModelPreference.modelId; + sessionStore.updateSession(homeSession.id, { + providerId: sessionModelPreference.providerId, + modelId: shouldClearHomeModel ? undefined : homeSession.modelId, + modelName: shouldClearHomeModel ? undefined : homeSession.modelName, + }); + if (sessionModelPreference.modelId) { + await acpSetModel(homeSession.id, sessionModelPreference.modelId); + sessionStore.updateSession(homeSession.id, { + modelId: sessionModelPreference.modelId, + modelName: sessionModelPreference.modelName, + }); + } + return homeSession; + } + + const workingDir = await resolveSessionCwd(null); + const sessionModelPreference = + await resolveSupportedSessionModelPreference( + agentStore.selectedProvider ?? "goose", + providerInventoryEntries, + ); + const session = await sessionStore.createSession({ + title: DEFAULT_CHAT_TITLE, + providerId: sessionModelPreference.providerId, + workingDir, + modelId: sessionModelPreference.modelId, + modelName: sessionModelPreference.modelName, + }); + setHomeSessionId(session.id); + return session; + })(); + + homeSessionRequestRef.current = request; + try { + return await request; + } finally { + if (homeSessionRequestRef.current === request) { + homeSessionRequestRef.current = null; + } + } + }, [ + agentStore.selectedProvider, + homeSession, + providerInventoryEntries, + projectStore.projects, + sessionStore.hasHydratedSessions, + sessionStore, + sessionStore.isLoading, + ]); + + useEffect(() => { + if (activeView !== "home") { + return; + } + void ensureHomeSession().catch((error) => { + console.error("Failed to ensure Home session:", error); + }); + }, [activeView, ensureHomeSession]); const createNewTab = useCallback( - (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { + async (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { const tStart = performance.now(); perfLog( `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, ); - const agentId = agentStore.activeAgentId ?? undefined; - const providerId = project?.preferredProvider ?? homeSelectedProvider; - const personaId = homeSelectedPersonaId; + const providerId = + project?.preferredProvider ?? agentStore.selectedProvider ?? "goose"; + const sessionModelPreference = + await resolveSupportedSessionModelPreference( + providerId, + providerInventoryEntries, + project?.preferredModel ?? undefined, + ); const sessionState = useChatSessionStore.getState(); - const chatStoreState = useChatStore.getState(); + const chatState = useChatStore.getState(); const existingDraft = findExistingDraft({ sessions: sessionState.sessions, activeSessionId: sessionState.activeSessionId, - draftsBySession: chatStoreState.draftsBySession, - messagesBySession: chatStoreState.messagesBySession, + draftsBySession: chatState.draftsBySession, + messagesBySession: chatState.messagesBySession, request: { title, projectId: project?.id, - agentId, - providerId, - personaId, }, }); if (existingDraft) { - if (sessionState.activeSessionId !== existingDraft.id) { - cleanupEmptyDraft(sessionState.activeSessionId); - } - sessionState.setActiveSession(existingDraft.id); + sessionStore.setActiveSession(existingDraft.id); setActiveView("chat"); chatStore.setActiveSession(existingDraft.id); perfLog( @@ -196,46 +306,44 @@ export function AppShell({ children }: { children?: React.ReactNode }) { ); return existingDraft; } - cleanupEmptyDraft(sessionState.activeSessionId); - const session = sessionStore.createDraftSession({ + + const workingDir = await resolveSessionCwd(project); + const session = await sessionStore.createSession({ title, projectId: project?.id, - agentId, - providerId, - personaId, + providerId: sessionModelPreference.providerId, + workingDir, + modelId: sessionModelPreference.modelId, + modelName: sessionModelPreference.modelName, }); sessionStore.setActiveSession(session.id); setActiveView("chat"); chatStore.setActiveSession(session.id); perfLog( - `[perf:newtab] ${session.id.slice(0, 8)} created draft in ${(performance.now() - tStart).toFixed(1)}ms`, + `[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`, ); return session; }, [ + agentStore.selectedProvider, chatStore, + providerInventoryEntries, sessionStore, - agentStore.activeAgentId, - homeSelectedPersonaId, - homeSelectedProvider, - cleanupEmptyDraft, ], ); const handleStartChatFromProject = useCallback( (project: ProjectInfo) => { - setHomeSelectedProvider(undefined); - createNewTab(DEFAULT_CHAT_TITLE, project); + void createNewTab(DEFAULT_CHAT_TITLE, project); }, [createNewTab], ); const handleNewChatInProject = useCallback( (projectId: string) => { - setHomeSelectedProvider(undefined); const project = projectStore.projects.find((p) => p.id === projectId); if (project) { - createNewTab(DEFAULT_CHAT_TITLE, project); + void createNewTab(DEFAULT_CHAT_TITLE, project); } }, [createNewTab, projectStore.projects], @@ -255,18 +363,41 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const clearActiveSession = useCallback( (sessionId: string) => { - cleanupEmptyDraft(sessionId); chatStore.cleanupSession(sessionId); sessionStore.setActiveSession(null); setActiveView("home"); }, - [chatStore, sessionStore, cleanupEmptyDraft], + [chatStore, sessionStore], ); const openSettings = useCallback((section: SectionId = "appearance") => { setSettingsInitialSection(section); setSettingsOpen(true); }, []); + useEffect(() => { + const handleOpenSettingsEvent = (event: Event) => { + const section = (event as CustomEvent<{ section?: string }>).detail + ?.section; + if (section && SETTINGS_SECTIONS.has(section as SectionId)) { + openSettings(section as SectionId); + return; + } + + openSettings(); + }; + + window.addEventListener( + OPEN_SETTINGS_EVENT, + handleOpenSettingsEvent as EventListener, + ); + return () => { + window.removeEventListener( + OPEN_SETTINGS_EVENT, + handleOpenSettingsEvent as EventListener, + ); + }; + }, [openSettings]); + const handleArchiveChat = useCallback( async (sessionId: string) => { const { activeSessionId: currentActiveSessionId } = @@ -306,7 +437,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { sessionStore.updateSession(sessionId, { projectId }); const session = useChatSessionStore.getState().getSession(sessionId); - if (!session || session.draft) { + if (!session) { return; } @@ -362,41 +493,28 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [], ); - const handleHomeStartChat = useCallback( - ( - initialMessage?: string, - providerId?: string, - personaId?: string, - projectId?: string | null, - attachments?: ChatAttachmentDraft[], - ) => { - setHomeSelectedProvider(providerId); - setHomeSelectedPersonaId(personaId); - setPendingInitialMessage(initialMessage); - setPendingInitialAttachments(attachments); - const selectedProject = - projectId != null - ? projectStore.projects.find((project) => project.id === projectId) - : undefined; - - createNewTab( - initialMessage?.slice(0, 40) || DEFAULT_CHAT_TITLE, - selectedProject, - ); + const activateHomeSession = useCallback( + (sessionId: string) => { + if (homeSessionId === sessionId) { + setHomeSessionId(null); + } + sessionStore.setActiveSession(sessionId); + setActiveView("chat"); + chatStore.setActiveSession(sessionId); + useChatStore.getState().markSessionRead(sessionId); }, - [createNewTab, projectStore.projects], + [chatStore, homeSessionId, sessionStore], ); const handleSelectSession = useCallback( (id: string) => { - cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId); sessionStore.setActiveSession(id); setActiveView("chat"); chatStore.setActiveSession(id); useChatStore.getState().markSessionRead(id); loadSessionMessages(id); }, - [sessionStore, chatStore, loadSessionMessages, cleanupEmptyDraft], + [sessionStore, chatStore, loadSessionMessages], ); const handleSelectSearchResult = useCallback( @@ -414,12 +532,15 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleNavigate = useCallback( (view: AppView) => { if (view !== "chat") { - cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId); sessionStore.setActiveSession(null); } setActiveView(view); }, - [sessionStore, cleanupEmptyDraft], + [sessionStore], + ); + + const handleCreatePersona = useCreatePersonaNavigation(() => + handleNavigate("agents"), ); const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); @@ -496,20 +617,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { // Cmd+N opens new conversation screen if (e.key === "n" && e.metaKey) { e.preventDefault(); - createNewTab(); + sessionStore.setActiveSession(null); + setActiveView("home"); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [clearActiveSession, createNewTab]); - - const activeSessionPersonaId = activeSession?.personaId; - const handleInitialMessageConsumed = useCallback(() => { - setPendingInitialMessage(undefined); - setPendingInitialAttachments(undefined); - setHomeSelectedProvider(undefined); - setHomeSelectedPersonaId(undefined); - }, []); + }, [clearActiveSession, sessionStore]); const editingProjectProp = useMemo( () => @@ -551,7 +665,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onCollapse={toggleSidebar} onNavigate={handleNavigate} onNewChatInProject={handleNewChatInProject} - onNewChat={() => createNewTab()} + onNewChat={() => { + sessionStore.setActiveSession(null); + setActiveView("home"); + }} onCreateProject={() => openCreateProjectDialog()} onEditProject={handleEditProject} onArchiveProject={handleArchiveProject} @@ -582,15 +699,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) { { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + export function useAppStartup() { useEffect(() => { (async () => { @@ -22,6 +29,7 @@ export function useAppStartup() { } const store = useAgentStore.getState(); + const inventoryStore = useProviderInventoryStore.getState(); const loadPersonas = async () => { const t0 = performance.now(); store.setPersonasLoading(true); @@ -56,6 +64,76 @@ export function useAppStartup() { } }; + const loadProviderInventory = async () => { + const t0 = performance.now(); + inventoryStore.setLoading(true); + try { + const { getProviderInventory } = await import( + "@/features/providers/api/inventory" + ); + const entries = await getProviderInventory(); + inventoryStore.setEntries(entries); + perfLog( + `[perf:startup] loadProviderInventory done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`, + ); + return entries; + } catch (err) { + console.error("Failed to load provider inventory on startup:", err); + return []; + } finally { + inventoryStore.setLoading(false); + } + }; + + const refreshConfiguredProviderInventory = async ( + initialEntries?: Awaited>, + ) => { + try { + const entries = + initialEntries && initialEntries.length > 0 + ? initialEntries + : await (async () => { + const { getProviderInventory } = await import( + "@/features/providers/api/inventory" + ); + return getProviderInventory(); + })(); + const configuredProviderIds = entries + .filter((entry) => entry.configured) + .map((entry) => entry.providerId); + if (configuredProviderIds.length === 0) { + return; + } + + const { getProviderInventory, refreshProviderInventory } = + await import("@/features/providers/api/inventory"); + const refresh = await refreshProviderInventory(configuredProviderIds); + if (refresh.started.length === 0) { + return; + } + + inventoryStore.mergeEntries( + await getProviderInventory(refresh.started), + ); + + for (const delayMs of INVENTORY_POLL_DELAYS_MS) { + await sleep(delayMs); + const refreshedEntries = await getProviderInventory( + refresh.started, + ); + inventoryStore.mergeEntries(refreshedEntries); + if (refreshedEntries.every((entry) => !entry.refreshing)) { + return; + } + } + } catch (err) { + console.error( + "Failed to refresh provider inventory on startup:", + err, + ); + } + }; + const loadSessionState = async () => { const t0 = performance.now(); perfLog("[perf:startup] loadSessionState start"); @@ -68,11 +146,17 @@ export function useAppStartup() { setActiveSession(null); }; + const inventoryLoad = loadProviderInventory(); + await Promise.allSettled([ loadPersonas(), loadProviders(), + inventoryLoad, loadSessionState(), ]); + void inventoryLoad.then((entries) => + refreshConfiguredProviderInventory(entries), + ); perfLog( `[perf:startup] useAppStartup complete in ${(performance.now() - tStartup).toFixed(1)}ms`, ); diff --git a/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts b/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts new file mode 100644 index 000000000000..9f25f485ef86 --- /dev/null +++ b/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts @@ -0,0 +1,17 @@ +import { useCallback } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; + +export function useCreatePersonaNavigation(navigateToAgents: () => void) { + return useCallback(() => { + navigateToAgents(); + const agentStoreState = useAgentStore.getState(); + if ( + agentStoreState.personaEditorOpen && + agentStoreState.personaEditorMode === "create" && + agentStoreState.editingPersona === null + ) { + return; + } + agentStoreState.openPersonaEditor(undefined, "create"); + }, [navigateToAgents]); +} diff --git a/ui/goose2/src/app/hooks/useHomeSessionStateSync.ts b/ui/goose2/src/app/hooks/useHomeSessionStateSync.ts new file mode 100644 index 000000000000..f55e8a9f01a0 --- /dev/null +++ b/ui/goose2/src/app/hooks/useHomeSessionStateSync.ts @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import { + hasSessionStarted, + type ChatSession, +} from "@/features/chat/stores/chatSessionStore"; +import { persistHomeSessionId } from "../lib/homeSessionStorage"; + +interface UseHomeSessionStateSyncOptions { + homeSessionId: string | null; + homeSession?: ChatSession; + messagesBySession: Record | undefined>; + hasHydratedSessions: boolean; + isLoading: boolean; + setHomeSessionId: (sessionId: string | null) => void; +} + +export function useHomeSessionStateSync({ + homeSessionId, + homeSession, + messagesBySession, + hasHydratedSessions, + isLoading, + setHomeSessionId, +}: UseHomeSessionStateSyncOptions): void { + useEffect(() => { + if (!homeSessionId || !hasHydratedSessions || isLoading) { + return; + } + + if ( + !homeSession || + homeSession.archivedAt || + hasSessionStarted(homeSession, messagesBySession[homeSession.id]) + ) { + setHomeSessionId(null); + } + }, [ + hasHydratedSessions, + homeSession, + homeSession?.archivedAt, + homeSession?.messageCount, + homeSessionId, + isLoading, + messagesBySession, + setHomeSessionId, + ]); + + useEffect(() => { + persistHomeSessionId(homeSessionId); + }, [homeSessionId]); +} diff --git a/ui/goose2/src/app/lib/homeSessionStorage.ts b/ui/goose2/src/app/lib/homeSessionStorage.ts new file mode 100644 index 000000000000..f4c04754349c --- /dev/null +++ b/ui/goose2/src/app/lib/homeSessionStorage.ts @@ -0,0 +1,27 @@ +const HOME_SESSION_STORAGE_KEY = "goose:home-session-id"; + +export function loadStoredHomeSessionId(): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY); + } catch { + return null; + } +} + +export function persistHomeSessionId(sessionId: string | null): void { + if (typeof window === "undefined") { + return; + } + try { + if (sessionId) { + window.localStorage.setItem(HOME_SESSION_STORAGE_KEY, sessionId); + return; + } + window.localStorage.removeItem(HOME_SESSION_STORAGE_KEY); + } catch { + // localStorage may be unavailable + } +} diff --git a/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.test.ts b/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.test.ts new file mode 100644 index 000000000000..98583b558280 --- /dev/null +++ b/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSupportedSessionModelPreference } from "./resolveSupportedSessionModelPreference"; + +const mockGetProviderInventory = vi.fn(); + +vi.mock("@/features/providers/api/inventory", () => ({ + getProviderInventory: (...args: unknown[]) => + mockGetProviderInventory(...args), +})); + +describe("resolveSupportedSessionModelPreference", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it("drops the model when provider inventory lookup fails", async () => { + window.localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + goose: { + modelId: "gpt-5.4", + modelName: "GPT-5.4", + providerId: "openai", + }, + }), + ); + mockGetProviderInventory.mockRejectedValue( + new Error("inventory unavailable"), + ); + + await expect( + resolveSupportedSessionModelPreference("goose", new Map()), + ).resolves.toEqual({ + providerId: "openai", + }); + }); + + it("drops the model when provider inventory has no matching entry", async () => { + window.localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + goose: { + modelId: "gpt-5.4", + modelName: "GPT-5.4", + providerId: "openai", + }, + }), + ); + mockGetProviderInventory.mockResolvedValue([]); + + await expect( + resolveSupportedSessionModelPreference("goose", new Map()), + ).resolves.toEqual({ + providerId: "openai", + }); + }); +}); diff --git a/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.ts b/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.ts new file mode 100644 index 000000000000..dedbd53ffb50 --- /dev/null +++ b/ui/goose2/src/app/lib/resolveSupportedSessionModelPreference.ts @@ -0,0 +1,36 @@ +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { + resolveSessionModelPreference, + sanitizeSessionModelPreference, + type SessionModelPreference, +} from "@/features/chat/lib/sessionModelPreference"; + +export async function resolveSupportedSessionModelPreference( + providerId: string, + inventoryEntries: Map, + preferredModel?: string, +): Promise { + const sessionModelPreference = resolveSessionModelPreference({ + providerId, + preferredModel, + }); + + if (!sessionModelPreference.modelId) { + return sessionModelPreference; + } + + const inventoryEntry = + inventoryEntries.get(sessionModelPreference.providerId) ?? + (await getProviderInventory([sessionModelPreference.providerId]) + .then(([entry]) => entry) + .catch(() => undefined)); + + if (!inventoryEntry) { + return { + providerId: sessionModelPreference.providerId, + }; + } + + return sanitizeSessionModelPreference(sessionModelPreference, inventoryEntry); +} diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index 4100082a687f..c7b1fde8a68a 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -5,31 +5,20 @@ import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ProjectInfo } from "@/features/projects/api/projects"; import type { AppView } from "../AppShell"; interface AppShellContentProps { activeView: AppView; activeSession?: ChatSession; - activeSessionPersonaId?: string; - homeSelectedProvider?: string; - homeSelectedPersonaId?: string; - pendingInitialMessage?: string; - pendingInitialAttachments?: ChatAttachmentDraft[]; + homeSessionId: string | null; + onCreatePersona: () => void; onArchiveChat: (sessionId: string) => Promise; onCreateProject: (options?: { initialWorkingDir?: string | null; onCreated?: (projectId: string) => void; }) => void; - onHomeStartChat: ( - initialMessage?: string, - providerId?: string, - personaId?: string, - projectId?: string | null, - attachments?: ChatAttachmentDraft[], - ) => void; - onInitialMessageConsumed: () => void; + onActivateHomeSession: (sessionId: string) => void; onRenameChat: (sessionId: string, nextTitle: string) => void; onSelectSession: (sessionId: string) => void; onSelectSearchResult: ( @@ -43,15 +32,11 @@ interface AppShellContentProps { export function AppShellContent({ activeView, activeSession, - activeSessionPersonaId, - homeSelectedProvider, - homeSelectedPersonaId, - pendingInitialMessage, - pendingInitialAttachments, + homeSessionId, + onCreatePersona, onArchiveChat, onCreateProject, - onHomeStartChat, - onInitialMessageConsumed, + onActivateHomeSession, onRenameChat, onSelectSession, onSelectSearchResult, @@ -74,21 +59,27 @@ export function AppShellContent({ /> ); case "chat": - case "home": return activeSession ? ( ) : ( + ); + case "home": + return ( + ); diff --git a/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts b/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts index a480588305d2..d1507c253edd 100644 --- a/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts +++ b/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts @@ -81,6 +81,7 @@ describe("usePersonas", () => { isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }); }); diff --git a/ui/goose2/src/features/agents/lib/personaImport.ts b/ui/goose2/src/features/agents/lib/personaImport.ts new file mode 100644 index 000000000000..99c0d3b135dc --- /dev/null +++ b/ui/goose2/src/features/agents/lib/personaImport.ts @@ -0,0 +1,58 @@ +const JSON_MIME_TYPES = new Set([ + "", + "application/json", + "application/x-json", + "text/json", + "text/plain", +]); + +export interface ImportMessageDescriptor { + key: + | "view.importInvalidExtension" + | "view.importInvalidMimeType" + | "view.imported_one" + | "view.imported_other"; + options?: Record; +} + +export function validatePersonaImportFile( + file: Pick, +): ImportMessageDescriptor | null { + const lowerName = file.name.toLowerCase(); + if (!lowerName.endsWith(".json")) { + return { + key: "view.importInvalidExtension", + } satisfies ImportMessageDescriptor; + } + + if (!JSON_MIME_TYPES.has(file.type)) { + return { + key: "view.importInvalidMimeType", + } satisfies ImportMessageDescriptor; + } + + return null; +} + +export function formatImportSuccessMessage( + importedCount: number, +): ImportMessageDescriptor { + if (importedCount === 1) { + return { key: "view.imported_one", options: { count: importedCount } }; + } + + return { + key: "view.imported_other", + options: { count: importedCount }, + }; +} + +export function formatAgentError(error: unknown, fallback: string): string { + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return fallback; +} diff --git a/ui/goose2/src/features/agents/lib/personaPresentation.ts b/ui/goose2/src/features/agents/lib/personaPresentation.ts new file mode 100644 index 000000000000..ee44c3585b69 --- /dev/null +++ b/ui/goose2/src/features/agents/lib/personaPresentation.ts @@ -0,0 +1,17 @@ +import type { Persona } from "@/shared/types/agents"; + +export type PersonaSource = "builtin" | "file" | "custom"; + +export function getPersonaSource(persona: Persona): PersonaSource { + if (persona.isBuiltin) { + return "builtin"; + } + if (persona.isFromDisk) { + return "file"; + } + return "custom"; +} + +export function isPersonaReadOnly(persona: Persona): boolean { + return getPersonaSource(persona) !== "custom"; +} diff --git a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts index 53275e93b3dd..5887c5877f71 100644 --- a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts +++ b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts @@ -44,6 +44,7 @@ describe("agentStore", () => { isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }); }); @@ -140,12 +141,14 @@ describe("agentStore", () => { useAgentStore.getState().openPersonaEditor(p); expect(useAgentStore.getState().personaEditorOpen).toBe(true); expect(useAgentStore.getState().editingPersona).toEqual(p); + expect(useAgentStore.getState().personaEditorMode).toBe("edit"); }); it("openPersonaEditor without persona sets editingPersona to null", () => { useAgentStore.getState().openPersonaEditor(); expect(useAgentStore.getState().personaEditorOpen).toBe(true); expect(useAgentStore.getState().editingPersona).toBeNull(); + expect(useAgentStore.getState().personaEditorMode).toBe("create"); }); it("closePersonaEditor clears editing state", () => { @@ -153,6 +156,7 @@ describe("agentStore", () => { useAgentStore.getState().closePersonaEditor(); expect(useAgentStore.getState().personaEditorOpen).toBe(false); expect(useAgentStore.getState().editingPersona).toBeNull(); + expect(useAgentStore.getState().personaEditorMode).toBe("create"); }); // ── helpers ─────────────────────────────────────────────────────── diff --git a/ui/goose2/src/features/agents/stores/agentStore.ts b/ui/goose2/src/features/agents/stores/agentStore.ts index e0f535529c84..e0e3eb2531f9 100644 --- a/ui/goose2/src/features/agents/stores/agentStore.ts +++ b/ui/goose2/src/features/agents/stores/agentStore.ts @@ -56,6 +56,7 @@ interface AgentStoreState { // UI state personaEditorOpen: boolean; editingPersona: Persona | null; + personaEditorMode: "create" | "edit" | "details"; } interface AgentStoreActions { @@ -83,7 +84,10 @@ interface AgentStoreActions { getActiveAgent: () => Agent | null; // Persona editor - openPersonaEditor: (persona?: Persona) => void; + openPersonaEditor: ( + persona?: Persona, + mode?: "create" | "edit" | "details", + ) => void; closePersonaEditor: () => void; // Loading @@ -112,6 +116,7 @@ export const useAgentStore = create((set, get) => ({ isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", // Persona CRUD setPersonas: (personas) => set({ personas }), @@ -187,16 +192,18 @@ export const useAgentStore = create((set, get) => ({ }, // Persona editor - openPersonaEditor: (persona) => + openPersonaEditor: (persona, mode) => set({ personaEditorOpen: true, editingPersona: persona ?? null, + personaEditorMode: mode ?? (persona ? "edit" : "create"), }), closePersonaEditor: () => set({ personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }), // Loading diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index 282f190318f1..f48942481f9f 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -1,7 +1,8 @@ -import { useState, useMemo, useCallback, useRef } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Bot, Plus, Circle, Upload } from "lucide-react"; -import { cn } from "@/shared/lib/cn"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Plus, Upload } from "lucide-react"; +import { toast } from "sonner"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Button, buttonVariants } from "@/shared/ui/button"; import { @@ -17,66 +18,36 @@ import { import { useAgentStore } from "@/features/agents/stores/agentStore"; import { PersonaGallery } from "@/features/agents/ui/PersonaGallery"; import { PersonaEditor } from "@/features/agents/ui/PersonaEditor"; -import { exportPersona, importPersonas } from "@/shared/api/agents"; +import { + exportPersona, + importPersonas, + readImportPersonaFile, +} from "@/shared/api/agents"; import { usePersonas } from "@/features/agents/hooks/usePersonas"; import type { Persona, - Agent, - AgentStatus, CreatePersonaRequest, UpdatePersonaRequest, } from "@/shared/types/agents"; - -const STATUS_STYLES: Record = { - online: { dot: "text-green-500", labelKey: "statuses.online" }, - offline: { dot: "text-muted-foreground", labelKey: "statuses.offline" }, - starting: { dot: "text-yellow-500", labelKey: "statuses.starting" }, - error: { dot: "text-red-500", labelKey: "statuses.error" }, -}; - -function AgentRow({ agent }: { agent: Agent }) { - const { t } = useTranslation("agents"); - const status = STATUS_STYLES[agent.status]; - return ( -
  • -
    - -
    -

    {agent.name}

    - {agent.persona && ( -

    - {agent.persona.displayName} -

    - )} -
    -
    -
    -
    -
  • - ); -} +import { + formatAgentError, + formatImportSuccessMessage, + validatePersonaImportFile, +} from "@/features/agents/lib/personaImport"; +import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; export function AgentsView() { const { t } = useTranslation(["agents", "common"]); const [search, setSearch] = useState(""); const [deletingPersona, setDeletingPersona] = useState(null); - const [notification, setNotification] = useState(null); const personas = useAgentStore((s) => s.personas); const personasLoading = useAgentStore((s) => s.personasLoading); - const agents = useAgentStore((s) => s.agents); const personaEditorOpen = useAgentStore((s) => s.personaEditorOpen); const editingPersona = useAgentStore((s) => s.editingPersona); + const personaEditorMode = useAgentStore((s) => s.personaEditorMode); const openPersonaEditor = useAgentStore((s) => s.openPersonaEditor); const closePersonaEditor = useAgentStore((s) => s.closePersonaEditor); - const addPersona = useAgentStore((s) => s.addPersona); const { createPersona, @@ -97,48 +68,54 @@ export function AgentsView() { [personas, lowerSearch], ); - const filteredAgents = useMemo( - () => - agents.filter( - (a) => - a.name.toLowerCase().includes(lowerSearch) || - a.persona?.displayName.toLowerCase().includes(lowerSearch), - ), - [agents, lowerSearch], - ); - const handleSavePersona = useCallback( async (data: CreatePersonaRequest | UpdatePersonaRequest) => { - if (editingPersona) { - await updatePersonaViaHook( - editingPersona.id, - data as UpdatePersonaRequest, - ); - } else { - await createPersona(data as CreatePersonaRequest); + try { + if (editingPersona && personaEditorMode === "edit") { + await updatePersonaViaHook( + editingPersona.id, + data as UpdatePersonaRequest, + ); + toast.success(t("editor.updated")); + } else { + await createPersona(data as CreatePersonaRequest); + toast.success(t("editor.created")); + } + closePersonaEditor(); + } catch (error) { + toast.error(formatAgentError(error, t("editor.saveFailed"))); } - closePersonaEditor(); }, - [editingPersona, createPersona, updatePersonaViaHook, closePersonaEditor], + [ + closePersonaEditor, + createPersona, + editingPersona, + personaEditorMode, + t, + updatePersonaViaHook, + ], ); const handleDuplicatePersona = useCallback( - (persona: Persona) => { - const duplicate: Persona = { - ...persona, - id: crypto.randomUUID(), - displayName: t("view.copyName", { name: persona.displayName }), - isBuiltin: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - addPersona(duplicate); + async (persona: Persona) => { + try { + await createPersona({ + displayName: t("view.copyName", { name: persona.displayName }), + avatar: persona.avatar ?? undefined, + systemPrompt: persona.systemPrompt, + provider: persona.provider, + model: persona.model, + }); + toast.success(t("editor.duplicated")); + } catch (error) { + toast.error(formatAgentError(error, t("editor.saveFailed"))); + } }, - [addPersona, t], + [createPersona, t], ); const handleDeletePersona = useCallback((persona: Persona) => { - if (persona.isBuiltin) return; + if (getPersonaSource(persona) === "builtin") return; setDeletingPersona(persona); }, []); @@ -146,11 +123,15 @@ export function AgentsView() { if (!deletingPersona) return; try { await deletePersona(deletingPersona.id); + if (editingPersona?.id === deletingPersona.id) { + closePersonaEditor(); + } + toast.success(t("view.deleted", { name: deletingPersona.displayName })); } catch (err) { - console.error("Failed to delete persona:", err); + toast.error(formatAgentError(err, t("view.deleteFailed"))); } setDeletingPersona(null); - }, [deletingPersona, deletePersona]); + }, [closePersonaEditor, deletingPersona, deletePersona, editingPersona, t]); const handleExportPersona = useCallback( async (persona: Persona) => { @@ -166,53 +147,77 @@ export function AgentsView() { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - setNotification( + toast.success( t("view.exportedTo", { filename: result.suggestedFilename }), ); - setTimeout(() => setNotification(null), 3000); } catch (err) { - console.error("Failed to export persona:", err); + toast.error(formatAgentError(err, t("view.exportFailed"))); } }, [t], ); - const importInputRef = useRef(null); - - const handleImportFile = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const arrayBuffer = await file.arrayBuffer(); - const bytes = Array.from(new Uint8Array(arrayBuffer)); - await importPersonas(bytes, file.name); - await refreshFromDisk(); - } catch (err) { - console.error("Failed to import persona:", err); - } + const handleImportError = useCallback((message: string) => { + toast.error(message); + }, []); - // Reset the input so the same file can be re-selected - if (importInputRef.current) { - importInputRef.current.value = ""; - } + const validateImportFile = useCallback( + (file: Pick) => { + const message = validatePersonaImportFile(file); + return message ? t(message.key, message.options) : null; }, - [refreshFromDisk], + [t], ); const handleImportFileBytes = useCallback( async (fileBytes: number[], fileName: string) => { try { - await importPersonas(fileBytes, fileName); + const imported = await importPersonas(fileBytes, fileName); await refreshFromDisk(); + const message = formatImportSuccessMessage(imported.length); + toast.success(t(message.key, message.options)); } catch (err) { - console.error("Failed to import persona:", err); + toast.error(formatAgentError(err, t("view.importFailed"))); } }, - [refreshFromDisk], + [refreshFromDisk, t], ); + const handleImportPicker = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + directory: false, + title: t("common:actions.import"), + filters: [ + { + name: "JSON", + extensions: ["json"], + }, + ], + }); + + if (!selected || Array.isArray(selected)) { + return; + } + + const { fileBytes, fileName } = await readImportPersonaFile(selected); + const validationMessage = validateImportFile({ + name: fileName, + type: "", + }); + + if (validationMessage) { + toast.error(validationMessage); + return; + } + + await handleImportFileBytes(fileBytes, fileName); + } catch (err) { + toast.error(formatAgentError(err, t("view.importFailed"))); + } + }, [handleImportFileBytes, t, validateImportFile]); + return (
    @@ -228,18 +233,11 @@ export function AgentsView() {

    -
    @@ -313,9 +284,12 @@ export function AgentsView() { openPersonaEditor(persona, "edit")} + onDelete={handleDeletePersona} /> {/* Delete confirmation dialog */} @@ -343,13 +317,6 @@ export function AgentsView() { - - {/* Export notification toast */} - {notification && ( -
    - {notification} -
    - )}
    ); } diff --git a/ui/goose2/src/features/agents/ui/PersonaCard.tsx b/ui/goose2/src/features/agents/ui/PersonaCard.tsx index 5128b8c2b6b6..83e2c9cb5faf 100644 --- a/ui/goose2/src/features/agents/ui/PersonaCard.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaCard.tsx @@ -13,6 +13,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import type { Persona } from "@/shared/types/agents"; +import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; interface PersonaCardProps { persona: Persona; @@ -38,18 +39,30 @@ export function PersonaCard({ const initials = persona.displayName.charAt(0).toUpperCase(); const avatarSrc = useAvatarSrc(persona.avatar); + const personaSource = getPersonaSource(persona); + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const providerModelLabel = [persona.provider, persona.model] + .filter(Boolean) + .join(" / "); + + const handleCardKeyDown = (event: React.KeyboardEvent) => { + if (event.target !== event.currentTarget || menuOpen) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect?.(persona); + } + }; return ( -
    !menuOpen && onSelect?.(persona)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect?.(persona); - } - }} - // biome-ignore lint/a11y/noNoninteractiveTabindex: card needs keyboard focus but contains nested interactive buttons + onKeyDown={handleCardKeyDown} tabIndex={0} className={cn( "group relative flex flex-col items-center gap-3 rounded-xl border p-5 cursor-pointer", @@ -68,6 +81,7 @@ export function PersonaCard({ size="icon-xs" aria-label={t("card.options")} onClick={(e) => e.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} className={cn( "size-6 rounded-md text-muted-foreground hover:text-foreground", menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100", @@ -77,10 +91,12 @@ export function PersonaCard({ - onEdit?.(persona)}> - - {t("common:actions.edit")} - + {canEditPersona && ( + onEdit?.(persona)}> + + {t("common:actions.edit")} + + )} onDuplicate?.(persona)}> {t("common:actions.duplicate")} @@ -89,7 +105,7 @@ export function PersonaCard({ {t("common:actions.export")} - {!persona.isBuiltin && !persona.isFromDisk && ( + {canDeletePersona && ( onDelete?.(persona)} @@ -116,11 +132,16 @@ export function PersonaCard({ {/* Built-in badge */} - {persona.isBuiltin && ( + {personaSource === "builtin" && ( {t("common:labels.builtIn")} )} + {personaSource === "file" && ( + + {t("card.fileBacked")} + + )} {/* System prompt preview */}

    @@ -128,15 +149,13 @@ export function PersonaCard({

    {/* Provider/model badge */} - {(persona.provider || persona.model) && ( - - {persona.provider && {persona.provider}} - {persona.provider && persona.model && ( - - )} - {persona.model && {persona.model}} + {providerModelLabel && ( + + + {providerModelLabel} + )} -
    +
    ); } diff --git a/ui/goose2/src/features/agents/ui/PersonaDetails.tsx b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx new file mode 100644 index 000000000000..6022c35f544b --- /dev/null +++ b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { + Avatar as AvatarRoot, + AvatarFallback, + AvatarImage, +} from "@/shared/ui/avatar"; +import { Badge } from "@/shared/ui/badge"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; +import type { Avatar } from "@/shared/types/agents"; +import type { PersonaSource } from "@/features/agents/lib/personaPresentation"; + +interface PersonaDetailsProps { + avatar: Avatar | null; + displayName: string; + modelLabel: string; + personaSource: PersonaSource; + providerLabel: string; + systemPrompt: string; +} + +export function PersonaDetails({ + avatar, + displayName, + modelLabel, + personaSource, + providerLabel, + systemPrompt, +}: PersonaDetailsProps) { + const { t } = useTranslation(["agents", "common"]); + const avatarSrc = useAvatarSrc(avatar); + const initials = displayName.charAt(0).toUpperCase() || "?"; + + return ( +
    +
    +
    +
    + + + + {initials} + + +
    +
    +

    + {t("editor.displayName")} +

    +

    + {displayName} +

    +
    +
    + {personaSource === "builtin" ? ( + + {t("common:labels.builtIn")} + + ) : null} + {personaSource === "file" ? ( + {t("card.fileBacked")} + ) : null} +
    +
    +
    +
    + +
    +
    +

    + {t("editor.provider")} +

    +

    + {providerLabel} +

    +
    +
    +

    + {t("editor.model")} +

    +

    {modelLabel}

    +
    +
    + +
    +
    +

    + {t("editor.systemPrompt")} +

    + + {t("common:labels.characterCount", { + count: systemPrompt.length, + })} + +
    +
    + + {systemPrompt} + +
    +
    +
    +
    + ); +} diff --git a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx index 8c460d3a220a..7655f6afe507 100644 --- a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Copy } from "lucide-react"; +import { Copy, Pencil, Trash2 } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Avatar as AvatarRoot, @@ -11,6 +11,7 @@ import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; import { Textarea } from "@/shared/ui/textarea"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import { Dialog, DialogContent, @@ -25,46 +26,60 @@ import { SelectTrigger, SelectValue, } from "@/shared/ui/select"; +import type { Persona, ProviderType, Avatar } from "@/shared/types/agents"; import type { - Persona, - ProviderType, - Avatar, CreatePersonaRequest, UpdatePersonaRequest, } from "@/shared/types/agents"; -import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp"; +import { discoverAcpProviders } from "@/shared/api/acp"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { + getPersonaSource, + isPersonaReadOnly, +} from "@/features/agents/lib/personaPresentation"; import { AvatarDropZone } from "./AvatarDropZone"; +import { PersonaDetails } from "./PersonaDetails"; interface PersonaEditorProps { persona?: Persona; isOpen: boolean; + mode?: "create" | "edit" | "details"; onClose: () => void; onSave: (data: CreatePersonaRequest | UpdatePersonaRequest) => void; onDuplicate?: (persona: Persona) => void; + onEdit?: (persona: Persona) => void; + onDelete?: (persona: Persona) => void; isPending?: boolean; } export function PersonaEditor({ persona, isOpen, + mode = "create", onClose, onSave, onDuplicate, + onEdit, + onDelete, isPending = false, }: PersonaEditorProps) { const { t } = useTranslation(["agents", "common"]); - const isEditing = !!persona; - const isReadOnly = persona?.isBuiltin ?? false; - - const [acpProviders, setAcpProviders] = useState([]); - - useEffect(() => { - if (isOpen) { - discoverAcpProviders() - .then(setAcpProviders) - .catch(() => setAcpProviders([])); - } - }, [isOpen]); + const isEditing = mode === "edit"; + const detailsMode = mode === "details"; + const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false; + const isReadOnly = detailsMode || readOnlyBySource; + const personaSource = persona ? getPersonaSource(persona) : "custom"; + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const acpProviders = useAgentStore((s) => s.providers); + const setProviders = useAgentStore((s) => s.setProviders); + const mergeInventoryEntries = useProviderInventoryStore( + (s) => s.mergeEntries, + ); + const { getEntry, getModelsForProvider } = useProviderInventory(); const [displayName, setDisplayName] = useState(""); const [avatar, setAvatar] = useState(null); @@ -72,6 +87,36 @@ export function PersonaEditor({ const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); + useEffect(() => { + if (!isOpen) { + return; + } + + let cancelled = false; + + const syncProviderOptions = async () => { + try { + const providers = await discoverAcpProviders(); + if (!cancelled) { + setProviders(providers); + } + } catch {} + + try { + const entries = await getProviderInventory(); + if (!cancelled) { + mergeInventoryEntries(entries); + } + } catch {} + }; + + void syncProviderOptions(); + + return () => { + cancelled = true; + }; + }, [isOpen, mergeInventoryEntries, setProviders]); + useEffect(() => { if (isOpen && persona) { setDisplayName(persona.displayName); @@ -90,6 +135,29 @@ export function PersonaEditor({ const isValid = displayName.trim().length > 0 && systemPrompt.trim().length > 0; + const avatarSrc = useAvatarSrc(avatar); + + const availableModels = provider ? getModelsForProvider(provider) : []; + const providerInventory = provider ? getEntry(provider) : undefined; + const modelStatusMessage = + providerInventory?.modelSelectionHint ?? + providerInventory?.lastRefreshError; + const hasSavedModelOutsideInventory = + Boolean(model) && !availableModels.some((entry) => entry.id === model); + const modelSelectValue = hasSavedModelOutsideInventory + ? `__saved__:${model}` + : model || "__none__"; + + const readOnlyDescription = readOnlyBySource + ? personaSource === "builtin" + ? t("editor.readOnlyBuiltIn") + : t("editor.readOnlyFile") + : null; + const providerLabel = provider + ? (acpProviders.find((providerOption) => providerOption.id === provider) + ?.label ?? provider) + : t("common:labels.none"); + const modelLabel = model || t("common:labels.none"); const handleSubmit = useCallback( (e: React.FormEvent) => { @@ -127,147 +195,259 @@ export function PersonaEditor({ - {isReadOnly + {detailsMode ? persona?.displayName : isEditing ? t("editor.editTitle") : t("editor.newTitle")} + {readOnlyDescription ? ( +

    + {readOnlyDescription} +

    + ) : null}
    -
    - {/* Avatar drop zone */} -
    - {isReadOnly ? ( - - + ) : ( + +
    + {isReadOnly ? ( + + + + {initials} + + + ) : ( + - - {initials} - - - ) : ( - - )} -
    - - {/* Display Name */} -
    - - setDisplayName(e.target.value)} - readOnly={isReadOnly} - required - placeholder={t("editor.displayNamePlaceholder")} - className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} - /> -
    + )} +
    - {/* System Prompt */} -
    -
    +
    - - {t("common:labels.characterCount", { - count: systemPrompt.length, - })} - + setDisplayName(e.target.value)} + readOnly={isReadOnly} + required + placeholder={t("editor.displayNamePlaceholder")} + className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} + />
    -